From 692185f7d22167e7e062f24b95df5c3885890798 Mon Sep 17 00:00:00 2001 From: Mikael Mengistu Date: Wed, 10 Oct 2018 09:58:25 -0700 Subject: [PATCH 1/6] Upgrade Java client to RTM versioning (#3104) --- version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.props b/version.props index 20e7eec17c..c370c87035 100644 --- a/version.props +++ b/version.props @@ -1,7 +1,7 @@  1.1.0 - 0.1.0 + 1.0.0 preview3 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix)-final From be4fe6c6f9fb33b6b0348d8f9018ddb07729f5db Mon Sep 17 00:00:00 2001 From: Mikael Mengistu Date: Wed, 10 Oct 2018 12:03:26 -0700 Subject: [PATCH 2/6] Wait for handshake response in Java Client(#3068) --- .../signalr/HttpHubConnectionBuilder.java | 11 +++- .../com/microsoft/signalr/HubConnection.java | 56 ++++++++++++++----- .../signalr/HandshakeProtocolTest.java | 6 ++ .../microsoft/signalr/HubConnectionTest.java | 50 +++++++++++++---- .../com/microsoft/signalr/MockTransport.java | 17 ++++++ .../java/com/microsoft/signalr/TestUtils.java | 2 +- 6 files changed, 113 insertions(+), 29 deletions(-) diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java index c2594826a8..d18c0a8d35 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java @@ -3,8 +3,7 @@ package com.microsoft.signalr; -import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; +import java.time.Duration; import io.reactivex.Single; @@ -15,6 +14,7 @@ public class HttpHubConnectionBuilder { private HttpClient httpClient; private boolean skipNegotiate; private Single accessTokenProvider; + private Duration handshakeResponseTimeout; HttpHubConnectionBuilder(String url) { this.url = url; @@ -56,7 +56,12 @@ public class HttpHubConnectionBuilder { return this; } + HttpHubConnectionBuilder withHandshakeResponseTimeout(Duration timeout) { + this.handshakeResponseTimeout = timeout; + return this; + } + public HubConnection build() { - return new HubConnection(url, transport, skipNegotiate, logger, httpClient, accessTokenProvider); + return new HubConnection(url, transport, skipNegotiate, logger, httpClient, accessTokenProvider, handshakeResponseTimeout); } } \ No newline at end of file diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java index 9d926023cc..a904f9dfbf 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java @@ -4,11 +4,12 @@ package com.microsoft.signalr; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -38,8 +39,10 @@ public class HubConnection { private ConnectionState connectionState = null; private HttpClient httpClient; private String stopError; + private CompletableFuture handshakeResponseFuture; + private Duration handshakeResponseTimeout = Duration.ofSeconds(15); - HubConnection(String url, Transport transport, boolean skipNegotiate, Logger logger, HttpClient httpClient, Single accessTokenProvider) { + HubConnection(String url, Transport transport, boolean skipNegotiate, Logger logger, HttpClient httpClient, Single accessTokenProvider, Duration handshakeResponseTimeout) { if (url == null || url.isEmpty()) { throw new IllegalArgumentException("A valid url is required."); } @@ -69,19 +72,33 @@ public class HubConnection { this.transport = transport; } + if (handshakeResponseTimeout != null) { + this.handshakeResponseTimeout = handshakeResponseTimeout; + } + this.skipNegotiate = skipNegotiate; this.callback = (payload) -> { if (!handshakeReceived) { int handshakeLength = payload.indexOf(RECORD_SEPARATOR) + 1; String handshakeResponseString = payload.substring(0, handshakeLength - 1); - HandshakeResponseMessage handshakeResponse = HandshakeProtocol.parseHandshakeResponse(handshakeResponseString); + HandshakeResponseMessage handshakeResponse; + try { + handshakeResponse = HandshakeProtocol.parseHandshakeResponse(handshakeResponseString); + } catch (RuntimeException ex) { + RuntimeException exception = new RuntimeException("An invalid handshake response was received from the server.", ex); + handshakeResponseFuture.completeExceptionally(exception); + throw exception; + } if (handshakeResponse.getHandshakeError() != null) { String errorMessage = "Error in handshake " + handshakeResponse.getHandshakeError(); logger.log(LogLevel.Error, errorMessage); - throw new RuntimeException(errorMessage); + RuntimeException exception = new RuntimeException(errorMessage); + handshakeResponseFuture.completeExceptionally(exception); + throw exception; } handshakeReceived = true; + handshakeResponseFuture.complete(null); payload = payload.substring(handshakeLength); // The payload only contained the handshake response so we can return. @@ -134,6 +151,12 @@ public class HubConnection { }; } + private void timeoutHandshakeResponse(long timeout, TimeUnit unit) { + ScheduledExecutorService scheduledThreadPool = Executors.newSingleThreadScheduledExecutor(); + scheduledThreadPool.schedule(() -> handshakeResponseFuture.completeExceptionally( + new TimeoutException("Timed out waiting for the server to respond to the handshake message.")), timeout, unit); + } + private CompletableFuture handleNegotiate(String url) { HttpRequest request = new HttpRequest(); request.addHeaders(this.headers); @@ -184,8 +207,9 @@ public class HubConnection { return Completable.complete(); } + handshakeResponseFuture = new CompletableFuture<>(); handshakeReceived = false; - CompletableFuture tokenFuture = new CompletableFuture<>(); + CompletableFuture tokenFuture = new CompletableFuture<>(); accessTokenProvider.subscribe(token -> { if (token != null && !token.isEmpty()) { this.headers.put("Authorization", "Bearer " + token); @@ -213,15 +237,18 @@ public class HubConnection { return transport.start(url).thenCompose((future) -> { String handshake = HandshakeProtocol.createHandshakeRequestMessage( new HandshakeRequestMessage(protocol.getName(), protocol.getVersion())); - return transport.send(handshake).thenRun(() -> { - hubConnectionStateLock.lock(); - try { - hubConnectionState = HubConnectionState.CONNECTED; - connectionState = new ConnectionState(this); - logger.log(LogLevel.Information, "HubConnection started."); - } finally { - hubConnectionStateLock.unlock(); - } + return transport.send(handshake).thenCompose((innerFuture) -> { + timeoutHandshakeResponse(handshakeResponseTimeout.toMillis(), TimeUnit.MILLISECONDS); + return handshakeResponseFuture.thenRun(() -> { + hubConnectionStateLock.lock(); + try { + hubConnectionState = HubConnectionState.CONNECTED; + connectionState = new ConnectionState(this); + logger.log(LogLevel.Information, "HubConnection started."); + } finally { + hubConnectionStateLock.unlock(); + } + }); }); }); })); @@ -308,6 +335,7 @@ public class HubConnection { connectionState = null; logger.log(LogLevel.Information, "HubConnection stopped."); hubConnectionState = HubConnectionState.DISCONNECTED; + handshakeResponseFuture.complete(null); } finally { hubConnectionStateLock.unlock(); } diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/HandshakeProtocolTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/HandshakeProtocolTest.java index 98be91207c..811d39c558 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/HandshakeProtocolTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/HandshakeProtocolTest.java @@ -29,4 +29,10 @@ class HandshakeProtocolTest { HandshakeResponseMessage hsr = HandshakeProtocol.parseHandshakeResponse(handshakeResponseWithError); assertEquals(hsr.getHandshakeError(), "Requested protocol 'messagepack' is not available."); } + + @Test + public void InvalidHandshakeResponse() { + String handshakeResponseWithError = "{\"error\": \"Requested proto"; + Throwable exception = assertThrows(RuntimeException.class, ()-> HandshakeProtocol.parseHandshakeResponse(handshakeResponseWithError)); + } } \ No newline at end of file diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java index c76fc4adad..1cc7550a3c 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java @@ -5,6 +5,7 @@ package com.microsoft.signalr; import static org.junit.jupiter.api.Assertions.*; +import java.time.Duration; import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -34,7 +35,7 @@ class HubConnectionTest { @Test public void transportCloseTriggersStopInHubConnection() throws Exception { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(true); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -45,7 +46,7 @@ class HubConnectionTest { @Test public void transportCloseWithErrorTriggersStopInHubConnection() throws Exception { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(true); AtomicReference message = new AtomicReference<>(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); String errorMessage = "Example transport error."; @@ -58,12 +59,27 @@ class HubConnectionTest { assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); mockTransport.stopWithError(errorMessage); assertEquals(errorMessage, message.get()); + } + + @Test + public void checkHubConnectionStateNoHandShakeResponse() { + MockTransport mockTransport = new MockTransport(); + HubConnection hubConnection = HubConnectionBuilder.create("http://example.com") + .withTransport(mockTransport) + .withHttpClient(new TestHttpClient()) + .shouldSkipNegotiate(true) + .withHandshakeResponseTimeout(Duration.ofMillis(100)) + .build(); + Throwable exception = assertThrows(RuntimeException.class, () -> hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS)); + assertEquals(ExecutionException.class, exception.getCause().getClass()); + assertEquals(TimeoutException.class, exception.getCause().getCause().getClass()); + assertEquals(exception.getCause().getCause().getMessage(), "Timed out waiting for the server to respond to the handshake message."); assertEquals(HubConnectionState.DISCONNECTED, hubConnection.getConnectionState()); } @Test - public void constructHubConnectionWithHttpConnectionOptions() throws Exception { - Transport mockTransport = new MockTransport(); + public void constructHubConnectionWithHttpConnectionOptions() { + Transport mockTransport = new MockTransport(true); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); @@ -88,6 +104,18 @@ class HubConnectionTest { assertEquals(HubConnectionState.DISCONNECTED, hubConnection.getConnectionState()); } + @Test + public void invalidHandShakeResponse() throws Exception { + MockTransport mockTransport = new MockTransport(); + HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); + + hubConnection.start(); + + Throwable exception = assertThrows(RuntimeException.class, () -> mockTransport.receiveMessage("{" + RECORD_SEPARATOR)); + assertEquals("An invalid handshake response was received from the server.", exception.getMessage()); + assertEquals(HubConnectionState.DISCONNECTED, hubConnection.getConnectionState()); + } + @Test public void hubConnectionReceiveHandshakeResponseWithError() { MockTransport mockTransport = new MockTransport(); @@ -958,7 +986,7 @@ class HubConnectionTest { "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\",\"" + "availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}]}"))); - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -977,7 +1005,7 @@ class HubConnectionTest { TestHttpClient client = new TestHttpClient().on("POST", "http://example.com/negotiate", (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"error\":\"Test error.\"}"))); - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withHttpClient(client) @@ -997,7 +1025,7 @@ class HubConnectionTest { (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\",\"" + "availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}]}"))); - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -1022,7 +1050,7 @@ class HubConnectionTest { + "availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}]}")); }); - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -1048,7 +1076,7 @@ class HubConnectionTest { + "availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}]}")); }); - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -1065,7 +1093,7 @@ class HubConnectionTest { @Test public void hubConnectionCanBeStartedAfterBeingStopped() throws Exception { - MockTransport transport = new MockTransport(); + MockTransport transport = new MockTransport(true); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -1084,7 +1112,7 @@ class HubConnectionTest { @Test public void hubConnectionCanBeStartedAfterBeingStoppedAndRedirected() throws Exception { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(true); TestHttpClient client = new TestHttpClient() .on("POST", "http://example.com/negotiate", (req) -> CompletableFuture .completedFuture(new HttpResponse(200, "", "{\"url\":\"http://testexample.com/\"}"))) diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java index 8390a34918..10677d56f4 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java @@ -12,10 +12,27 @@ class MockTransport implements Transport { private ArrayList sentMessages = new ArrayList<>(); private String url; private Consumer onClose; + private boolean autoHandshake; + + private static final String RECORD_SEPARATOR = "\u001e"; + + public MockTransport() { + } + + public MockTransport(boolean autoHandshake) { + this.autoHandshake = autoHandshake; + } @Override public CompletableFuture start(String url) { this.url = url; + if (autoHandshake) { + try { + onReceiveCallBack.invoke("{}" + RECORD_SEPARATOR); + } catch (Exception e) { + throw new RuntimeException(e); + } + } return CompletableFuture.completedFuture(null); } diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/TestUtils.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/TestUtils.java index eebf0a1968..af8366a6f4 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/TestUtils.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/TestUtils.java @@ -5,7 +5,7 @@ package com.microsoft.signalr; class TestUtils { static HubConnection createHubConnection(String url) { - return createHubConnection(url, new MockTransport(), new NullLogger(), true, new TestHttpClient()); + return createHubConnection(url, new MockTransport(true), new NullLogger(), true, new TestHttpClient()); } static HubConnection createHubConnection(String url, Transport transport) { From 2ee351786f66f1ac437c824977516ebc04f9a01e Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Wed, 10 Oct 2018 13:49:22 -0700 Subject: [PATCH 3/6] [Java] Add pings and server timeout (#3027) --- .../com/microsoft/signalr/HubConnection.java | 69 +++++++++++++++- .../com/microsoft/signalr/PingMessage.java | 2 + .../microsoft/signalr/HubConnectionTest.java | 79 ++++++++++--------- .../com/microsoft/signalr/MockTransport.java | 13 ++- 4 files changed, 123 insertions(+), 40 deletions(-) diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java index a904f9dfbf..f799c6ddb1 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java @@ -6,11 +6,15 @@ package com.microsoft.signalr; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -39,9 +43,36 @@ public class HubConnection { private ConnectionState connectionState = null; private HttpClient httpClient; private String stopError; + private Timer pingTimer = null; + private AtomicLong nextServerTimeout = new AtomicLong(); + private AtomicLong nextPingActivation = new AtomicLong(); + private Duration keepAliveInterval = Duration.ofSeconds(15); + private Duration serverTimeout = Duration.ofSeconds(30); + private Duration tickRate = Duration.ofSeconds(1); private CompletableFuture handshakeResponseFuture; private Duration handshakeResponseTimeout = Duration.ofSeconds(15); + public void setServerTimeout(Duration serverTimeout) { + this.serverTimeout = serverTimeout; + } + + public Duration getServerTimeout() { + return this.serverTimeout; + } + + public void setKeepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + public Duration getKeepAliveInterval() { + return this.keepAliveInterval; + } + + // For testing purposes + void setTickRate(Duration tickRate) { + this.tickRate = tickRate; + } + HubConnection(String url, Transport transport, boolean skipNegotiate, Logger logger, HttpClient httpClient, Single accessTokenProvider, Duration handshakeResponseTimeout) { if (url == null || url.isEmpty()) { throw new IllegalArgumentException("A valid url is required."); @@ -79,6 +110,7 @@ public class HubConnection { this.skipNegotiate = skipNegotiate; this.callback = (payload) -> { + resetServerTimeout(); if (!handshakeReceived) { int handshakeLength = payload.indexOf(RECORD_SEPARATOR) + 1; String handshakeResponseString = payload.substring(0, handshakeLength - 1); @@ -245,6 +277,29 @@ public class HubConnection { hubConnectionState = HubConnectionState.CONNECTED; connectionState = new ConnectionState(this); logger.log(LogLevel.Information, "HubConnection started."); + + resetServerTimeout(); + this.pingTimer = new Timer(); + this.pingTimer.schedule(new TimerTask() { + @Override + public void run() { + try { + if (System.currentTimeMillis() > nextServerTimeout.get()) { + stop("Server timeout elapsed without receiving a message from the server."); + return; + } + + if (System.currentTimeMillis() > nextPingActivation.get()) { + sendHubMessage(PingMessage.getInstance()); + } + } catch (Exception e) { + logger.log(LogLevel.Warning, String.format("Error sending ping: %s", e.getMessage())); + // The connection is probably in a bad or closed state now, cleanup the timer so + // it stops triggering + pingTimer.cancel(); + } + } + }, new Date(0), tickRate.toMillis()); } finally { hubConnectionStateLock.unlock(); } @@ -399,11 +454,21 @@ public class HubConnection { private void sendHubMessage(HubMessage message) throws Exception { String serializedMessage = protocol.writeMessage(message); if (message.getMessageType() == HubMessageType.INVOCATION) { - logger.log(LogLevel.Debug, "Sending %d message '%s'.", message.getMessageType().value, ((InvocationMessage)message).getInvocationId()); + logger.log(LogLevel.Debug, "Sending %s message '%s'.", message.getMessageType().name(), ((InvocationMessage)message).getInvocationId()); } else { - logger.log(LogLevel.Debug, "Sending %d message.", message.getMessageType().value); + logger.log(LogLevel.Debug, "Sending %s message.", message.getMessageType().name()); } transport.send(serializedMessage); + + resetKeepAlive(); + } + + private void resetServerTimeout() { + this.nextServerTimeout.set(System.currentTimeMillis() + serverTimeout.toMillis()); + } + + private void resetKeepAlive() { + this.nextPingActivation.set(System.currentTimeMillis() + keepAliveInterval.toMillis()); } /** diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java index 6e6e04d5dc..318de059dd 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java @@ -5,6 +5,8 @@ package com.microsoft.signalr; class PingMessage extends HubMessage { + int type = HubMessageType.PING.value; + private static PingMessage instance = new PingMessage(); private PingMessage() diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java index 1cc7550a3c..ba11f88ce1 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java @@ -35,7 +35,7 @@ class HubConnectionTest { @Test public void transportCloseTriggersStopInHubConnection() throws Exception { - MockTransport mockTransport = new MockTransport(true); + MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -46,7 +46,7 @@ class HubConnectionTest { @Test public void transportCloseWithErrorTriggersStopInHubConnection() throws Exception { - MockTransport mockTransport = new MockTransport(true); + MockTransport mockTransport = new MockTransport(); AtomicReference message = new AtomicReference<>(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); String errorMessage = "Example transport error."; @@ -63,7 +63,7 @@ class HubConnectionTest { @Test public void checkHubConnectionStateNoHandShakeResponse() { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = HubConnectionBuilder.create("http://example.com") .withTransport(mockTransport) .withHttpClient(new TestHttpClient()) @@ -79,7 +79,7 @@ class HubConnectionTest { @Test public void constructHubConnectionWithHttpConnectionOptions() { - Transport mockTransport = new MockTransport(true); + Transport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); @@ -95,7 +95,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -106,7 +105,7 @@ class HubConnectionTest { @Test public void invalidHandShakeResponse() throws Exception { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); @@ -118,7 +117,7 @@ class HubConnectionTest { @Test public void hubConnectionReceiveHandshakeResponseWithError() { - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); @@ -145,7 +144,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. @@ -169,7 +167,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. @@ -197,7 +194,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that the handler was removed. @@ -223,7 +219,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); assertEquals(Double.valueOf(3), value.get()); @@ -253,7 +248,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. @@ -286,7 +280,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. @@ -322,7 +315,6 @@ class HubConnectionTest { assertEquals(expectedHanshakeRequest, message); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. assertEquals(Double.valueOf(3), value.get()); @@ -346,7 +338,6 @@ class HubConnectionTest { assertEquals(Double.valueOf(0), value.get()); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); try { mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); @@ -371,7 +362,6 @@ class HubConnectionTest { assertEquals(Double.valueOf(0), value.get()); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"add\",\"arguments\":[12]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -384,7 +374,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); AtomicBoolean done = new AtomicBoolean(); Single result = hubConnection.invoke(Integer.class, "echo", "message"); @@ -403,7 +392,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); AtomicBoolean doneFirst = new AtomicBoolean(); AtomicBoolean doneSecond = new AtomicBoolean(); @@ -430,7 +418,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); AtomicBoolean done = new AtomicBoolean(); // int.class is a primitive type and since we use Class.cast to cast an Object to the expected return type @@ -450,7 +437,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); AtomicBoolean done = new AtomicBoolean(); Single result = hubConnection.invoke(int.class, "echo", "message"); @@ -476,7 +462,6 @@ class HubConnectionTest { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); AtomicBoolean done = new AtomicBoolean(); Single result = hubConnection.invoke(int.class, "echo", "message"); @@ -508,7 +493,6 @@ class HubConnectionTest { }); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[]}" + RECORD_SEPARATOR); // Confirming that our handler was called and that the counter property was incremented. @@ -527,7 +511,6 @@ class HubConnectionTest { }, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"Hello World\"]}" + RECORD_SEPARATOR); hubConnection.send("inc", "Hello World"); @@ -552,7 +535,6 @@ class HubConnectionTest { }, String.class, Double.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"Hello World\", 12]}" + RECORD_SEPARATOR); hubConnection.send("inc", "Hello World", 12); @@ -581,7 +563,6 @@ class HubConnectionTest { }, String.class, String.class, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\"]}" + RECORD_SEPARATOR); hubConnection.send("inc", "A", "B", "C"); @@ -614,7 +595,6 @@ class HubConnectionTest { }, String.class, String.class, String.class, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\", \"D\"]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -650,7 +630,6 @@ class HubConnectionTest { }, String.class, String.class, String.class, Boolean.class, Double.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\",true,12 ]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -690,7 +669,6 @@ class HubConnectionTest { }, String.class, String.class, String.class, Boolean.class, Double.class, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\",true,12,\"D\"]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -734,7 +712,6 @@ class HubConnectionTest { }, String.class, String.class, String.class, Boolean.class, Double.class, String.class, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\",true,12,\"D\",\"E\"]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -782,7 +759,6 @@ class HubConnectionTest { }, String.class, String.class, String.class, Boolean.class, Double.class, String.class, String.class, String.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[\"A\", \"B\", \"C\",true,12,\"D\",\"E\",\"F\"]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. assertEquals("A", value1.get()); @@ -815,7 +791,6 @@ class HubConnectionTest { }, Custom.class); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); mockTransport.receiveMessage("{\"type\":1,\"target\":\"inc\",\"arguments\":[{\"number\":1,\"str\":\"A\",\"bools\":[true,false]}]}" + RECORD_SEPARATOR); // Confirming that our handler was called and the correct message was passed in. @@ -830,7 +805,7 @@ class HubConnectionTest { @Test public void receiveHandshakeResponseAndMessage() throws Exception { AtomicReference value = new AtomicReference(0.0); - MockTransport mockTransport = new MockTransport(); + MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.on("inc", () ->{ @@ -898,7 +873,6 @@ class HubConnectionTest { assertEquals(ex.getMessage(), "There was an error"); }); hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -938,7 +912,6 @@ class HubConnectionTest { }, String.class); Completable startFuture = hubConnection.start(); - mockTransport.receiveMessage("{}" + RECORD_SEPARATOR); startFuture.blockingAwait(1000, TimeUnit.MILLISECONDS); RuntimeException exception = assertThrows(RuntimeException.class, () -> mockTransport.receiveMessage("{\"type\":1,\"target\":\"Send\",\"arguments\":[]}" + RECORD_SEPARATOR)); @@ -1091,9 +1064,43 @@ class HubConnectionTest { assertEquals("Bearer newToken", token.get()); } + @Test + public void connectionTimesOutIfServerDoesNotSendMessage() throws Exception { + HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); + hubConnection.setServerTimeout(Duration.ofMillis(1)); + hubConnection.setTickRate(Duration.ofMillis(1)); + CompletableFuture closedFuture = new CompletableFuture<>(); + hubConnection.onClosed((e) -> { + closedFuture.complete(e); + }); + + hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); + + assertEquals("Server timeout elapsed without receiving a message from the server.", closedFuture.get(1000, TimeUnit.MILLISECONDS).getMessage()); + } + + @Test + public void connectionSendsPingsRegularly() throws InterruptedException, ExecutionException, TimeoutException, Exception { + MockTransport mockTransport = new MockTransport(true, false); + HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); + hubConnection.setKeepAliveInterval(Duration.ofMillis(1)); + hubConnection.setTickRate(Duration.ofMillis(1)); + + hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); + + TimeUnit.MILLISECONDS.sleep(100); + hubConnection.stop(); + + String[] sentMessages = mockTransport.getSentMessages(); + assertTrue(sentMessages.length > 1); + for (int i = 1; i < sentMessages.length; i++) { + assertEquals("{\"type\":6}" + RECORD_SEPARATOR, sentMessages[i]); + } + } + @Test public void hubConnectionCanBeStartedAfterBeingStopped() throws Exception { - MockTransport transport = new MockTransport(true); + MockTransport transport = new MockTransport(); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") .withTransport(transport) @@ -1112,7 +1119,7 @@ class HubConnectionTest { @Test public void hubConnectionCanBeStartedAfterBeingStoppedAndRedirected() throws Exception { - MockTransport mockTransport = new MockTransport(true); + MockTransport mockTransport = new MockTransport(); TestHttpClient client = new TestHttpClient() .on("POST", "http://example.com/negotiate", (req) -> CompletableFuture .completedFuture(new HttpResponse(200, "", "{\"url\":\"http://testexample.com/\"}"))) diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java index 10677d56f4..b77e0f7999 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java @@ -12,15 +12,22 @@ class MockTransport implements Transport { private ArrayList sentMessages = new ArrayList<>(); private String url; private Consumer onClose; - private boolean autoHandshake; + final private boolean ignorePings; + final private boolean autoHandshake; private static final String RECORD_SEPARATOR = "\u001e"; public MockTransport() { + this(true, true); } public MockTransport(boolean autoHandshake) { + this(autoHandshake, true); + } + + public MockTransport(boolean autoHandshake, boolean ignorePings) { this.autoHandshake = autoHandshake; + this.ignorePings = ignorePings; } @Override @@ -38,7 +45,9 @@ class MockTransport implements Transport { @Override public CompletableFuture send(String message) { - sentMessages.add(message); + if (!(ignorePings && message.equals("{\"type\":6}" + RECORD_SEPARATOR))) { + sentMessages.add(message); + } return CompletableFuture.completedFuture(null); } From 6861e89c8b3081a828c7dd51576d29615dafc48e Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Wed, 10 Oct 2018 16:31:14 -0700 Subject: [PATCH 4/6] [Java] Fix incorrect visibility and some minor cleanup (#3112) --- .../com/microsoft/signalr/CallbackMap.java | 2 +- .../microsoft/signalr/DefaultHttpClient.java | 4 ++-- .../microsoft/signalr/HandshakeProtocol.java | 2 +- .../signalr/HttpHubConnectionBuilder.java | 4 ++-- .../com/microsoft/signalr/HubConnection.java | 20 +++++++++---------- .../signalr/HubConnectionBuilder.java | 3 +-- .../com/microsoft/signalr/HubException.java | 2 ++ .../microsoft/signalr/InvocationHandler.java | 4 ++-- .../microsoft/signalr/InvocationRequest.java | 6 +++--- .../com/microsoft/signalr/PingMessage.java | 2 +- .../signalr/StreamInvocationMessage.java | 3 +-- .../com/microsoft/signalr/Subscription.java | 6 +++--- .../com/microsoft/signalr/TransferFormat.java | 2 +- .../microsoft/signalr/WebSocketTransport.java | 4 ++-- 14 files changed, 32 insertions(+), 32 deletions(-) diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/CallbackMap.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/CallbackMap.java index e5f7b58edd..134ab2ed09 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/CallbackMap.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/CallbackMap.java @@ -7,7 +7,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; class CallbackMap { - private Map> handlers = new ConcurrentHashMap<>(); + private final Map> handlers = new ConcurrentHashMap<>(); public InvocationHandler put(String target, ActionBase action, Class... classes) { InvocationHandler handler = new InvocationHandler(action, classes); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/DefaultHttpClient.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/DefaultHttpClient.java index 2f42e1f5f7..a2e81c8e70 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/DefaultHttpClient.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/DefaultHttpClient.java @@ -22,8 +22,8 @@ import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; -class DefaultHttpClient extends HttpClient { - private OkHttpClient client; +final class DefaultHttpClient extends HttpClient { + private final OkHttpClient client; private Logger logger; public DefaultHttpClient(Logger logger) { diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HandshakeProtocol.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HandshakeProtocol.java index ca7987c68c..4c1d2ad896 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HandshakeProtocol.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HandshakeProtocol.java @@ -5,7 +5,7 @@ package com.microsoft.signalr; import com.google.gson.Gson; -class HandshakeProtocol { +final class HandshakeProtocol { private static final Gson gson = new Gson(); private static final String RECORD_SEPARATOR = "\u001e"; diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java index d18c0a8d35..9d059ab8ba 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HttpHubConnectionBuilder.java @@ -8,7 +8,7 @@ import java.time.Duration; import io.reactivex.Single; public class HttpHubConnectionBuilder { - private String url; + private final String url; private Transport transport; private Logger logger; private HttpClient httpClient; @@ -56,7 +56,7 @@ public class HttpHubConnectionBuilder { return this; } - HttpHubConnectionBuilder withHandshakeResponseTimeout(Duration timeout) { + public HttpHubConnectionBuilder withHandshakeResponseTimeout(Duration timeout) { this.handshakeResponseTimeout = timeout; return this; } diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java index f799c6ddb1..c7cf0ae6a1 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java @@ -24,28 +24,28 @@ import io.reactivex.Single; public class HubConnection { private static final String RECORD_SEPARATOR = "\u001e"; - private static List> emptyArray = new ArrayList<>(); - private static int MAX_NEGOTIATE_ATTEMPTS = 100; + private static final List> emptyArray = new ArrayList<>(); + private static final int MAX_NEGOTIATE_ATTEMPTS = 100; - private String baseUrl; + private final String baseUrl; private Transport transport; private OnReceiveCallBack callback; - private CallbackMap handlers = new CallbackMap(); + private final CallbackMap handlers = new CallbackMap(); private HubProtocol protocol; private Boolean handshakeReceived = false; private HubConnectionState hubConnectionState = HubConnectionState.DISCONNECTED; - private Lock hubConnectionStateLock = new ReentrantLock(); + private final Lock hubConnectionStateLock = new ReentrantLock(); private Logger logger; private List> onClosedCallbackList; - private boolean skipNegotiate; + private final boolean skipNegotiate; private Single accessTokenProvider; - private Map headers = new HashMap<>(); + private final Map headers = new HashMap<>(); private ConnectionState connectionState = null; - private HttpClient httpClient; + private final HttpClient httpClient; private String stopError; private Timer pingTimer = null; - private AtomicLong nextServerTimeout = new AtomicLong(); - private AtomicLong nextPingActivation = new AtomicLong(); + private final AtomicLong nextServerTimeout = new AtomicLong(); + private final AtomicLong nextPingActivation = new AtomicLong(); private Duration keepAliveInterval = Duration.ofSeconds(15); private Duration serverTimeout = Duration.ofSeconds(30); private Duration tickRate = Duration.ofSeconds(1); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnectionBuilder.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnectionBuilder.java index c5bc761071..3aee028ee4 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnectionBuilder.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnectionBuilder.java @@ -4,12 +4,11 @@ package com.microsoft.signalr; public abstract class HubConnectionBuilder { - public static HttpHubConnectionBuilder create(String url) { if (url == null || url.isEmpty()) { throw new IllegalArgumentException("A valid url is required."); } - return new HttpHubConnectionBuilder(url); + return new HttpHubConnectionBuilder(url); } public abstract HubConnection build(); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java index 6c7872372f..a7a30a3d9b 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java @@ -4,6 +4,8 @@ package com.microsoft.signalr; public class HubException extends Exception { + private static final long serialVersionUID = -572019264269821519L; + public HubException() { } diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationHandler.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationHandler.java index 4d10df0faf..64f8ed3a1d 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationHandler.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationHandler.java @@ -7,8 +7,8 @@ import java.util.Arrays; import java.util.List; class InvocationHandler { - private List> classes; - private ActionBase action; + private final List> classes; + private final ActionBase action; InvocationHandler(ActionBase action, Class... classes) { this.action = action; diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationRequest.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationRequest.java index fded3b9fa0..d4fa34c66e 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationRequest.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationRequest.java @@ -6,9 +6,9 @@ package com.microsoft.signalr; import java.util.concurrent.CompletableFuture; class InvocationRequest { - private Class returnType; - private CompletableFuture pendingCall = new CompletableFuture<>(); - private String invocationId; + private final Class returnType; + private final CompletableFuture pendingCall = new CompletableFuture<>(); + private final String invocationId; InvocationRequest(Class returnType, String invocationId) { this.returnType = returnType; diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java index 318de059dd..1c0286f70e 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/PingMessage.java @@ -5,7 +5,7 @@ package com.microsoft.signalr; class PingMessage extends HubMessage { - int type = HubMessageType.PING.value; + private final int type = HubMessageType.PING.value; private static PingMessage instance = new PingMessage(); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/StreamInvocationMessage.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/StreamInvocationMessage.java index 968b655f3f..eb4ae0e479 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/StreamInvocationMessage.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/StreamInvocationMessage.java @@ -4,8 +4,7 @@ package com.microsoft.signalr; class StreamInvocationMessage extends InvocationMessage { - - int type = HubMessageType.STREAM_INVOCATION.value; + private final int type = HubMessageType.STREAM_INVOCATION.value; public StreamInvocationMessage(String invocationId, String target, Object[] arguments) { super(invocationId, target, arguments); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/Subscription.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/Subscription.java index 8c7bf2acce..0ce344959d 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/Subscription.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/Subscription.java @@ -6,9 +6,9 @@ package com.microsoft.signalr; import java.util.List; public class Subscription { - private CallbackMap handlers; - private InvocationHandler handler; - private String target; + private final CallbackMap handlers; + private final InvocationHandler handler; + private final String target; public Subscription(CallbackMap handlers, InvocationHandler handler, String target) { this.handlers = handlers; diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/TransferFormat.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/TransferFormat.java index 413404bea6..59a9dd5889 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/TransferFormat.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/TransferFormat.java @@ -3,7 +3,7 @@ package com.microsoft.signalr; -public enum TransferFormat { +enum TransferFormat { TEXT, BINARY } diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java index 2ade4048e1..2024310d85 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java @@ -13,8 +13,8 @@ class WebSocketTransport implements Transport { private Consumer onClose; private String url; private Logger logger; - private HttpClient client; - private Map headers; + private final HttpClient client; + private final Map headers; private static final String HTTP = "http"; private static final String HTTPS = "https"; From 286e4bebf75ca5a729cf0d2a33b941802dc0459d Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Wed, 10 Oct 2018 16:40:27 -0700 Subject: [PATCH 5/6] Add StackExchange.Redis 2.X.X package (#3089) --- SignalR.sln | 16 +- ....AspNetCore.SignalR.Microbenchmarks.csproj | 3 +- .../RedisHubLifetimeManagerBenchmark.cs | 25 +- .../RedisProtocolBenchmark.cs | 2 +- build/dependencies.props | 1 + .../Internal/MessagePackUtil.cs | 3 + .../Internal/RedisGroupCommand.cs | 3 + .../Internal/RedisInvocation.cs | 4 +- .../Internal/RedisProtocol.cs | 1 - .../Internal/AckHandler.cs | 117 ++++ .../Internal/GroupAction.cs | 15 + .../Internal/MessagePackUtil.cs | 68 ++ .../Internal/RedisChannels.cs | 75 +++ .../Internal/RedisGroupCommand.cs | 42 ++ .../Internal/RedisInvocation.cs | 35 ++ .../Internal/RedisLog.cs | 119 ++++ .../Internal/RedisProtocol.cs | 208 ++++++ .../Internal/RedisSubscriptionManager.cs | 63 ++ ...pNetCore.SignalR.StackExchangeRedis.csproj | 23 + .../RedisDependencyInjectionExtensions.cs | 69 ++ .../RedisHubLifetimeManager.cs | 593 ++++++++++++++++++ .../RedisOptions.cs | 50 ++ .../Docker.cs | 8 +- .../RedisProtocolTests.cs | 3 + .../Startup.cs | 2 +- .../TestConnectionMultiplexer.cs | 0 .../Docker.cs | 192 ++++++ .../EchoHub.cs | 31 + ...re.SignalR.StackExchangeRedis.Tests.csproj | 27 + ...RedisDependencyInjectionExtensionsTests.cs | 41 ++ .../RedisEndToEnd.cs | 198 ++++++ .../RedisHubLifetimeManagerTests.cs | 84 +++ .../RedisProtocolTests.cs | 202 ++++++ .../RedisServerFixture.cs | 64 ++ .../SkipIfDockerNotPresentAttribute.cs | 39 ++ .../Startup.cs | 51 ++ .../TestConnectionMultiplexer.cs | 376 +++++++++++ ...soft.AspNetCore.SignalR.Tests.Utils.csproj | 1 - 38 files changed, 2831 insertions(+), 23 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/AckHandler.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/GroupAction.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/MessagePackUtil.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisChannels.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisGroupCommand.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisInvocation.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisLog.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisProtocol.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisSubscriptionManager.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisDependencyInjectionExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisHubLifetimeManager.cs create mode 100644 src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisOptions.cs rename test/{Microsoft.AspNetCore.SignalR.Tests.Utils => Microsoft.AspNetCore.SignalR.Redis.Tests}/TestConnectionMultiplexer.cs (100%) create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Docker.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/EchoHub.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests.csproj create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisDependencyInjectionExtensionsTests.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisEndToEnd.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisHubLifetimeManagerTests.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisProtocolTests.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisServerFixture.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/SkipIfDockerNotPresentAttribute.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Startup.cs create mode 100644 test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/TestConnectionMultiplexer.cs diff --git a/SignalR.sln b/SignalR.sln index f53f22d4ca..7f925af865 100644 --- a/SignalR.sln +++ b/SignalR.sln @@ -89,7 +89,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Crankier", "benchmarkapps\C EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarkapps", "benchmarkapps", "{43F352F3-4E2B-4ED7-901B-36E6671251F5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.SignalR.Specification.Tests", "src\Microsoft.AspNetCore.SignalR.Specification.Tests\Microsoft.AspNetCore.SignalR.Specification.Tests.csproj", "{2B03333F-3ACD-474C-862B-FA97D3BA03B5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.Specification.Tests", "src\Microsoft.AspNetCore.SignalR.Specification.Tests\Microsoft.AspNetCore.SignalR.Specification.Tests.csproj", "{2B03333F-3ACD-474C-862B-FA97D3BA03B5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.StackExchangeRedis", "src\Microsoft.AspNetCore.SignalR.StackExchangeRedis\Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj", "{D1334F29-5C19-4C7B-B62D-0A2F23AFB31C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests", "test\Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests\Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests.csproj", "{A5006087-81B0-4C62-B847-50ED5C37069D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -213,6 +217,14 @@ Global {2B03333F-3ACD-474C-862B-FA97D3BA03B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {2B03333F-3ACD-474C-862B-FA97D3BA03B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B03333F-3ACD-474C-862B-FA97D3BA03B5}.Release|Any CPU.Build.0 = Release|Any CPU + {D1334F29-5C19-4C7B-B62D-0A2F23AFB31C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1334F29-5C19-4C7B-B62D-0A2F23AFB31C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1334F29-5C19-4C7B-B62D-0A2F23AFB31C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1334F29-5C19-4C7B-B62D-0A2F23AFB31C}.Release|Any CPU.Build.0 = Release|Any CPU + {A5006087-81B0-4C62-B847-50ED5C37069D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5006087-81B0-4C62-B847-50ED5C37069D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5006087-81B0-4C62-B847-50ED5C37069D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5006087-81B0-4C62-B847-50ED5C37069D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -247,6 +259,8 @@ Global {8C75AC94-C980-4FE1-9F79-6CED3C8665CE} = {43F352F3-4E2B-4ED7-901B-36E6671251F5} {8D3E3E7D-452B-44F4-86CA-111003EA11ED} = {43F352F3-4E2B-4ED7-901B-36E6671251F5} {2B03333F-3ACD-474C-862B-FA97D3BA03B5} = {DA69F624-5398-4884-87E4-B816698CDE65} + {D1334F29-5C19-4C7B-B62D-0A2F23AFB31C} = {DA69F624-5398-4884-87E4-B816698CDE65} + {A5006087-81B0-4C62-B847-50ED5C37069D} = {6A35B453-52EC-48AF-89CA-D4A69800F131} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7945A4E4-ACDB-4F6E-95CA-6AC6E7C2CD59} diff --git a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks.csproj b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks.csproj index d391a18436..0143f5ffae 100644 --- a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks.csproj +++ b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks.csproj @@ -17,8 +17,9 @@ - + + diff --git a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisHubLifetimeManagerBenchmark.cs b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisHubLifetimeManagerBenchmark.cs index 3e333ddd74..852fbdfa3c 100644 --- a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisHubLifetimeManagerBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisHubLifetimeManagerBenchmark.cs @@ -10,7 +10,7 @@ using BenchmarkDotNet.Attributes; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.SignalR.Internal; using Microsoft.AspNetCore.SignalR.Protocol; -using Microsoft.AspNetCore.SignalR.Redis; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis; using Microsoft.AspNetCore.SignalR.Tests; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -34,7 +34,8 @@ namespace Microsoft.AspNetCore.SignalR.Microbenchmarks [Params(2, 20)] public int ProtocolCount { get; set; } - [GlobalSetup] + // Re-enable micro-benchmark when https://github.com/aspnet/SignalR/issues/3088 is fixed + // [GlobalSetup] public void GlobalSetup() { var server = new TestRedisServer(); @@ -90,7 +91,7 @@ namespace Microsoft.AspNetCore.SignalR.Microbenchmarks _users.Add("EvenUser"); _users.Add("OddUser"); - _args = new object[] {"Foo"}; + _args = new object[] { "Foo" }; } private IEnumerable GenerateProtocols(int protocolCount) @@ -111,55 +112,55 @@ namespace Microsoft.AspNetCore.SignalR.Microbenchmarks } } - [Benchmark] + //[Benchmark] public async Task SendAll() { await _manager1.SendAllAsync("Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendGroup() { await _manager1.SendGroupAsync("Everyone", "Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendUser() { await _manager1.SendUserAsync("EvenUser", "Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendConnection() { await _manager1.SendConnectionAsync(_clients[0].Connection.ConnectionId, "Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendConnections() { await _manager1.SendConnectionsAsync(_sendIds, "Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendAllExcept() { await _manager1.SendAllExceptAsync("Test", _args, _excludedConnectionIds); } - [Benchmark] + //[Benchmark] public async Task SendGroupExcept() { await _manager1.SendGroupExceptAsync("Everyone", "Test", _args, _excludedConnectionIds); } - [Benchmark] + //[Benchmark] public async Task SendGroups() { await _manager1.SendGroupsAsync(_groups, "Test", _args); } - [Benchmark] + //[Benchmark] public async Task SendUsers() { await _manager1.SendUsersAsync(_users, "Test", _args); diff --git a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisProtocolBenchmark.cs b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisProtocolBenchmark.cs index 3008ed999c..f5e02e489b 100644 --- a/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisProtocolBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.SignalR.Microbenchmarks/RedisProtocolBenchmark.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using BenchmarkDotNet.Attributes; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.SignalR.Protocol; -using Microsoft.AspNetCore.SignalR.Redis.Internal; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal; namespace Microsoft.AspNetCore.SignalR.Microbenchmarks { diff --git a/build/dependencies.props b/build/dependencies.props index 826da88432..13a42ca540 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -62,6 +62,7 @@ 2.0.3 11.0.2 1.2.6 + 2.0.513 4.5.0 4.5.0 4.5.1 diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/MessagePackUtil.cs b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/MessagePackUtil.cs index d190bb74e8..b824d90394 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/MessagePackUtil.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/MessagePackUtil.cs @@ -1,3 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System; using System.Diagnostics; using System.Runtime.InteropServices; diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisGroupCommand.cs b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisGroupCommand.cs index a2ef82f373..3759da98ae 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisGroupCommand.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisGroupCommand.cs @@ -1,3 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + namespace Microsoft.AspNetCore.SignalR.Redis.Internal { public readonly struct RedisGroupCommand diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisInvocation.cs b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisInvocation.cs index e9cedbd5b0..a1a8a3ee07 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisInvocation.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisInvocation.cs @@ -1,5 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System.Collections.Generic; -using Microsoft.AspNetCore.SignalR.Internal; using Microsoft.AspNetCore.SignalR.Protocol; namespace Microsoft.AspNetCore.SignalR.Redis.Internal diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisProtocol.cs b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisProtocol.cs index 6d3c51659b..6eaeb2ee79 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisProtocol.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/Internal/RedisProtocol.cs @@ -8,7 +8,6 @@ using System.IO; using System.Runtime.InteropServices; using MessagePack; using Microsoft.AspNetCore.Internal; -using Microsoft.AspNetCore.SignalR.Internal; using Microsoft.AspNetCore.SignalR.Protocol; namespace Microsoft.AspNetCore.SignalR.Redis.Internal diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/AckHandler.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/AckHandler.cs new file mode 100644 index 0000000000..863fcdcb53 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/AckHandler.cs @@ -0,0 +1,117 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + internal class AckHandler : IDisposable + { + private readonly ConcurrentDictionary _acks = new ConcurrentDictionary(); + private readonly Timer _timer; + private readonly TimeSpan _ackThreshold = TimeSpan.FromSeconds(30); + private readonly TimeSpan _ackInterval = TimeSpan.FromSeconds(5); + private readonly object _lock = new object(); + private bool _disposed; + + public AckHandler() + { + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + bool restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _timer = new Timer(state => ((AckHandler)state).CheckAcks(), state: this, dueTime: _ackInterval, period: _ackInterval); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + + public Task CreateAck(int id) + { + lock (_lock) + { + if (_disposed) + { + return Task.CompletedTask; + } + + return _acks.GetOrAdd(id, _ => new AckInfo()).Tcs.Task; + } + } + + public void TriggerAck(int id) + { + if (_acks.TryRemove(id, out var ack)) + { + ack.Tcs.TrySetResult(null); + } + } + + private void CheckAcks() + { + if (_disposed) + { + return; + } + + var utcNow = DateTime.UtcNow; + + foreach (var pair in _acks) + { + var elapsed = utcNow - pair.Value.Created; + if (elapsed > _ackThreshold) + { + if (_acks.TryRemove(pair.Key, out var ack)) + { + ack.Tcs.TrySetCanceled(); + } + } + } + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + + _timer.Dispose(); + + foreach (var pair in _acks) + { + if (_acks.TryRemove(pair.Key, out var ack)) + { + ack.Tcs.TrySetCanceled(); + } + } + } + } + + private class AckInfo + { + public TaskCompletionSource Tcs { get; private set; } + public DateTime Created { get; private set; } + + public AckInfo() + { + Created = DateTime.UtcNow; + Tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/GroupAction.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/GroupAction.cs new file mode 100644 index 0000000000..e3aae4c006 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/GroupAction.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + // The size of the enum is defined by the protocol. Do not change it. If you need more than 255 items, + // add an additional enum. + public enum GroupAction : byte + { + // These numbers are used by the protocol, do not change them and always use explicit assignment + // when adding new items to this enum. 0 is intentionally omitted + Add = 1, + Remove = 2, + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/MessagePackUtil.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/MessagePackUtil.cs new file mode 100644 index 0000000000..7780bca988 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/MessagePackUtil.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using MessagePack; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + internal static class MessagePackUtil + { + public static int ReadArrayHeader(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadArrayHeader(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + public static int ReadMapHeader(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadMapHeader(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + public static string ReadString(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadString(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + public static byte[] ReadBytes(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadBytes(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + public static int ReadInt32(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadInt32(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + public static byte ReadByte(ref ReadOnlyMemory data) + { + var arr = GetArray(data); + var val = MessagePackBinary.ReadByte(arr.Array, arr.Offset, out var readSize); + data = data.Slice(readSize); + return val; + } + + private static ArraySegment GetArray(ReadOnlyMemory data) + { + var isArray = MemoryMarshal.TryGetArray(data, out var array); + Debug.Assert(isArray); + return array; + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisChannels.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisChannels.cs new file mode 100644 index 0000000000..f377392bb1 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisChannels.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + internal class RedisChannels + { + private readonly string _prefix; + + /// + /// Gets the name of the channel for sending to all connections. + /// + /// + /// The payload on this channel is objects containing + /// invocations to be sent to all connections + /// + public string All { get; } + + /// + /// Gets the name of the internal channel for group management messages. + /// + public string GroupManagement { get; } + + public RedisChannels(string prefix) + { + _prefix = prefix; + + All = prefix + ":all"; + GroupManagement = prefix + ":internal:groups"; + } + + /// + /// Gets the name of the channel for sending a message to a specific connection. + /// + /// The ID of the connection to get the channel for. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string Connection(string connectionId) + { + return _prefix + ":connection:" + connectionId; + } + + /// + /// Gets the name of the channel for sending a message to a named group of connections. + /// + /// The name of the group to get the channel for. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string Group(string groupName) + { + return _prefix + ":group:" + groupName; + } + + /// + /// Gets the name of the channel for sending a message to all collections associated with a user. + /// + /// The ID of the user to get the channel for. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string User(string userId) + { + return _prefix + ":user:" + userId; + } + + /// + /// Gets the name of the acknowledgement channel for the specified server. + /// + /// The name of the server to get the acknowledgement channel for. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string Ack(string serverName) + { + return _prefix + ":internal:ack:" + serverName; + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisGroupCommand.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisGroupCommand.cs new file mode 100644 index 0000000000..1cb155d4aa --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisGroupCommand.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + public readonly struct RedisGroupCommand + { + /// + /// Gets the ID of the group command. + /// + public int Id { get; } + + /// + /// Gets the name of the server that sent the command. + /// + public string ServerName { get; } + + /// + /// Gets the action to be performed on the group. + /// + public GroupAction Action { get; } + + /// + /// Gets the group on which the action is performed. + /// + public string GroupName { get; } + + /// + /// Gets the ID of the connection to be added or removed from the group. + /// + public string ConnectionId { get; } + + public RedisGroupCommand(int id, string serverName, GroupAction action, string groupName, string connectionId) + { + Id = id; + ServerName = serverName; + Action = action; + GroupName = groupName; + ConnectionId = connectionId; + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisInvocation.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisInvocation.cs new file mode 100644 index 0000000000..aae0e88e59 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisInvocation.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.SignalR.Protocol; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + public readonly struct RedisInvocation + { + /// + /// Gets a list of connections that should be excluded from this invocation. + /// May be null to indicate that no connections are to be excluded. + /// + public IReadOnlyList ExcludedConnectionIds { get; } + + /// + /// Gets the message serialization cache containing serialized payloads for the message. + /// + public SerializedHubMessage Message { get; } + + public RedisInvocation(SerializedHubMessage message, IReadOnlyList excludedConnectionIds) + { + Message = message; + ExcludedConnectionIds = excludedConnectionIds; + } + + public static RedisInvocation Create(string target, object[] arguments, IReadOnlyList excludedConnectionIds = null) + { + return new RedisInvocation( + new SerializedHubMessage(new InvocationMessage(target, null, arguments)), + excludedConnectionIds); + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisLog.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisLog.cs new file mode 100644 index 0000000000..bd8d228ee3 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisLog.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + // We don't want to use our nested static class here because RedisHubLifetimeManager is generic. + // We'd end up creating separate instances of all the LoggerMessage.Define values for each Hub. + internal static class RedisLog + { + private static readonly Action _connectingToEndpoints = + LoggerMessage.Define(LogLevel.Information, new EventId(1, "ConnectingToEndpoints"), "Connecting to Redis endpoints: {Endpoints}. Using Server Name: {ServerName}"); + + private static readonly Action _connected = + LoggerMessage.Define(LogLevel.Information, new EventId(2, "Connected"), "Connected to Redis."); + + private static readonly Action _subscribing = + LoggerMessage.Define(LogLevel.Trace, new EventId(3, "Subscribing"), "Subscribing to channel: {Channel}."); + + private static readonly Action _receivedFromChannel = + LoggerMessage.Define(LogLevel.Trace, new EventId(4, "ReceivedFromChannel"), "Received message from Redis channel {Channel}."); + + private static readonly Action _publishToChannel = + LoggerMessage.Define(LogLevel.Trace, new EventId(5, "PublishToChannel"), "Publishing message to Redis channel {Channel}."); + + private static readonly Action _unsubscribe = + LoggerMessage.Define(LogLevel.Trace, new EventId(6, "Unsubscribe"), "Unsubscribing from channel: {Channel}."); + + private static readonly Action _notConnected = + LoggerMessage.Define(LogLevel.Error, new EventId(7, "Connected"), "Not connected to Redis."); + + private static readonly Action _connectionRestored = + LoggerMessage.Define(LogLevel.Information, new EventId(8, "ConnectionRestored"), "Connection to Redis restored."); + + private static readonly Action _connectionFailed = + LoggerMessage.Define(LogLevel.Error, new EventId(9, "ConnectionFailed"), "Connection to Redis failed."); + + private static readonly Action _failedWritingMessage = + LoggerMessage.Define(LogLevel.Warning, new EventId(10, "FailedWritingMessage"), "Failed writing message."); + + private static readonly Action _internalMessageFailed = + LoggerMessage.Define(LogLevel.Warning, new EventId(11, "InternalMessageFailed"), "Error processing message for internal server message."); + + public static void ConnectingToEndpoints(ILogger logger, EndPointCollection endpoints, string serverName) + { + if (logger.IsEnabled(LogLevel.Information)) + { + if (endpoints.Count > 0) + { + _connectingToEndpoints(logger, string.Join(", ", endpoints.Select(e => EndPointCollection.ToString(e))), serverName, null); + } + } + } + + public static void Connected(ILogger logger) + { + _connected(logger, null); + } + + public static void Subscribing(ILogger logger, string channelName) + { + _subscribing(logger, channelName, null); + } + + public static void ReceivedFromChannel(ILogger logger, string channelName) + { + _receivedFromChannel(logger, channelName, null); + } + + public static void PublishToChannel(ILogger logger, string channelName) + { + _publishToChannel(logger, channelName, null); + } + + public static void Unsubscribe(ILogger logger, string channelName) + { + _unsubscribe(logger, channelName, null); + } + + public static void NotConnected(ILogger logger) + { + _notConnected(logger, null); + } + + public static void ConnectionRestored(ILogger logger) + { + _connectionRestored(logger, null); + } + + public static void ConnectionFailed(ILogger logger, Exception exception) + { + _connectionFailed(logger, exception); + } + + public static void FailedWritingMessage(ILogger logger, Exception exception) + { + _failedWritingMessage(logger, exception); + } + + public static void InternalMessageFailed(ILogger logger, Exception exception) + { + _internalMessageFailed(logger, exception); + } + + // This isn't DefineMessage-based because it's just the simple TextWriter logging from ConnectionMultiplexer + public static void ConnectionMultiplexerMessage(ILogger logger, string message) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + // We tag it with EventId 100 though so it can be pulled out of logs easily. + logger.LogDebug(new EventId(100, "RedisConnectionLog"), message); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisProtocol.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisProtocol.cs new file mode 100644 index 0000000000..5185b946c9 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisProtocol.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MessagePack; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.SignalR.Protocol; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + public class RedisProtocol + { + private readonly IReadOnlyList _protocols; + + public RedisProtocol(IReadOnlyList protocols) + { + _protocols = protocols; + } + + // The Redis Protocol: + // * The message type is known in advance because messages are sent to different channels based on type + // * Invocations are sent to the All, Group, Connection and User channels + // * Group Commands are sent to the GroupManagement channel + // * Acks are sent to the Acknowledgement channel. + // * See the Write[type] methods for a description of the protocol for each in-depth. + // * The "Variable length integer" is the length-prefixing format used by BinaryReader/BinaryWriter: + // * https://docs.microsoft.com/en-us/dotnet/api/system.io.binarywriter.write?view=netstandard-2.0 + // * The "Length prefixed string" is the string format used by BinaryReader/BinaryWriter: + // * A 7-bit variable length integer encodes the length in bytes, followed by the encoded string in UTF-8. + + public byte[] WriteInvocation(string methodName, object[] args) => + WriteInvocation(methodName, args, excludedConnectionIds: null); + + public byte[] WriteInvocation(string methodName, object[] args, IReadOnlyList excludedConnectionIds) + { + // Written as a MessagePack 'arr' containing at least these items: + // * A MessagePack 'arr' of 'str's representing the excluded ids + // * [The output of WriteSerializedHubMessage, which is an 'arr'] + // Any additional items are discarded. + + var writer = MemoryBufferWriter.Get(); + + try + { + MessagePackBinary.WriteArrayHeader(writer, 2); + if (excludedConnectionIds != null && excludedConnectionIds.Count > 0) + { + MessagePackBinary.WriteArrayHeader(writer, excludedConnectionIds.Count); + foreach (var id in excludedConnectionIds) + { + MessagePackBinary.WriteString(writer, id); + } + } + else + { + MessagePackBinary.WriteArrayHeader(writer, 0); + } + + WriteSerializedHubMessage(writer, + new SerializedHubMessage(new InvocationMessage(methodName, args))); + return writer.ToArray(); + } + finally + { + MemoryBufferWriter.Return(writer); + } + } + + public byte[] WriteGroupCommand(RedisGroupCommand command) + { + // Written as a MessagePack 'arr' containing at least these items: + // * An 'int': the Id of the command + // * A 'str': The server name + // * An 'int': The action (likely less than 0x7F and thus a single-byte fixnum) + // * A 'str': The group name + // * A 'str': The connection Id + // Any additional items are discarded. + + var writer = MemoryBufferWriter.Get(); + try + { + MessagePackBinary.WriteArrayHeader(writer, 5); + MessagePackBinary.WriteInt32(writer, command.Id); + MessagePackBinary.WriteString(writer, command.ServerName); + MessagePackBinary.WriteByte(writer, (byte)command.Action); + MessagePackBinary.WriteString(writer, command.GroupName); + MessagePackBinary.WriteString(writer, command.ConnectionId); + + return writer.ToArray(); + } + finally + { + MemoryBufferWriter.Return(writer); + } + } + + public byte[] WriteAck(int messageId) + { + // Written as a MessagePack 'arr' containing at least these items: + // * An 'int': The Id of the command being acknowledged. + // Any additional items are discarded. + + var writer = MemoryBufferWriter.Get(); + try + { + MessagePackBinary.WriteArrayHeader(writer, 1); + MessagePackBinary.WriteInt32(writer, messageId); + + return writer.ToArray(); + } + finally + { + MemoryBufferWriter.Return(writer); + } + } + + public RedisInvocation ReadInvocation(ReadOnlyMemory data) + { + // See WriteInvocation for the format + ValidateArraySize(ref data, 2, "Invocation"); + + // Read excluded Ids + IReadOnlyList excludedConnectionIds = null; + var idCount = MessagePackUtil.ReadArrayHeader(ref data); + if (idCount > 0) + { + var ids = new string[idCount]; + for (var i = 0; i < idCount; i++) + { + ids[i] = MessagePackUtil.ReadString(ref data); + } + + excludedConnectionIds = ids; + } + + // Read payload + var message = ReadSerializedHubMessage(ref data); + return new RedisInvocation(message, excludedConnectionIds); + } + + public RedisGroupCommand ReadGroupCommand(ReadOnlyMemory data) + { + // See WriteGroupCommand for format. + ValidateArraySize(ref data, 5, "GroupCommand"); + + var id = MessagePackUtil.ReadInt32(ref data); + var serverName = MessagePackUtil.ReadString(ref data); + var action = (GroupAction)MessagePackUtil.ReadByte(ref data); + var groupName = MessagePackUtil.ReadString(ref data); + var connectionId = MessagePackUtil.ReadString(ref data); + + return new RedisGroupCommand(id, serverName, action, groupName, connectionId); + } + + public int ReadAck(ReadOnlyMemory data) + { + // See WriteAck for format + ValidateArraySize(ref data, 1, "Ack"); + return MessagePackUtil.ReadInt32(ref data); + } + + private void WriteSerializedHubMessage(Stream stream, SerializedHubMessage message) + { + // Written as a MessagePack 'map' where the keys are the name of the protocol (as a MessagePack 'str') + // and the values are the serialized blob (as a MessagePack 'bin'). + + MessagePackBinary.WriteMapHeader(stream, _protocols.Count); + + foreach (var protocol in _protocols) + { + MessagePackBinary.WriteString(stream, protocol.Name); + + var serialized = message.GetSerializedMessage(protocol); + var isArray = MemoryMarshal.TryGetArray(serialized, out var array); + Debug.Assert(isArray); + MessagePackBinary.WriteBytes(stream, array.Array, array.Offset, array.Count); + } + } + + public static SerializedHubMessage ReadSerializedHubMessage(ref ReadOnlyMemory data) + { + var count = MessagePackUtil.ReadMapHeader(ref data); + var serializations = new SerializedMessage[count]; + for (var i = 0; i < count; i++) + { + var protocol = MessagePackUtil.ReadString(ref data); + var serialized = MessagePackUtil.ReadBytes(ref data); + serializations[i] = new SerializedMessage(protocol, serialized); + } + + return new SerializedHubMessage(serializations); + } + + private static void ValidateArraySize(ref ReadOnlyMemory data, int expectedLength, string messageType) + { + var length = MessagePackUtil.ReadArrayHeader(ref data); + + if (length < expectedLength) + { + throw new InvalidDataException($"Insufficient items in {messageType} array."); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisSubscriptionManager.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisSubscriptionManager.cs new file mode 100644 index 0000000000..18863dbce3 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Internal/RedisSubscriptionManager.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal +{ + internal class RedisSubscriptionManager + { + private readonly ConcurrentDictionary _subscriptions = new ConcurrentDictionary(StringComparer.Ordinal); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + public async Task AddSubscriptionAsync(string id, HubConnectionContext connection, Func subscribeMethod) + { + await _lock.WaitAsync(); + + try + { + var subscription = _subscriptions.GetOrAdd(id, _ => new HubConnectionStore()); + + subscription.Add(connection); + + // Subscribe once + if (subscription.Count == 1) + { + await subscribeMethod(id, subscription); + } + } + finally + { + _lock.Release(); + } + } + + public async Task RemoveSubscriptionAsync(string id, HubConnectionContext connection, Func unsubscribeMethod) + { + await _lock.WaitAsync(); + + try + { + if (!_subscriptions.TryGetValue(id, out var subscription)) + { + return; + } + + subscription.Remove(connection); + + if (subscription.Count == 0) + { + _subscriptions.TryRemove(id, out _); + await unsubscribeMethod(id); + } + } + finally + { + _lock.Release(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj new file mode 100644 index 0000000000..f1fabc764b --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj @@ -0,0 +1,23 @@ + + + + Provides scale-out support for ASP.NET Core SignalR using a Redis server and the StackExchange.Redis client. + netstandard2.0 + + + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisDependencyInjectionExtensions.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisDependencyInjectionExtensions.cs new file mode 100644 index 0000000000..6be7e6ff65 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisDependencyInjectionExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis; +using StackExchange.Redis; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for configuring Redis-based scale-out for a SignalR Server in an . + /// + public static class StackExchangeRedisDependencyInjectionExtensions + { + /// + /// Adds scale-out to a , using a shared Redis server. + /// + /// The . + /// The same instance of the for chaining. + public static ISignalRServerBuilder AddStackExchangeRedis(this ISignalRServerBuilder signalrBuilder) + { + return AddStackExchangeRedis(signalrBuilder, o => { }); + } + + /// + /// Adds scale-out to a , using a shared Redis server. + /// + /// The . + /// The connection string used to connect to the Redis server. + /// The same instance of the for chaining. + public static ISignalRServerBuilder AddStackExchangeRedis(this ISignalRServerBuilder signalrBuilder, string redisConnectionString) + { + return AddStackExchangeRedis(signalrBuilder, o => + { + o.Configuration = ConfigurationOptions.Parse(redisConnectionString); + }); + } + + /// + /// Adds scale-out to a , using a shared Redis server. + /// + /// The . + /// A callback to configure the Redis options. + /// The same instance of the for chaining. + public static ISignalRServerBuilder AddStackExchangeRedis(this ISignalRServerBuilder signalrBuilder, Action configure) + { + signalrBuilder.Services.Configure(configure); + signalrBuilder.Services.AddSingleton(typeof(HubLifetimeManager<>), typeof(RedisHubLifetimeManager<>)); + return signalrBuilder; + } + + /// + /// Adds scale-out to a , using a shared Redis server. + /// + /// The . + /// The connection string used to connect to the Redis server. + /// A callback to configure the Redis options. + /// The same instance of the for chaining. + public static ISignalRServerBuilder AddStackExchangeRedis(this ISignalRServerBuilder signalrBuilder, string redisConnectionString, Action configure) + { + return AddStackExchangeRedis(signalrBuilder, o => + { + o.Configuration = ConfigurationOptions.Parse(redisConnectionString); + configure(o); + }); + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisHubLifetimeManager.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisHubLifetimeManager.cs new file mode 100644 index 0000000000..17b462bfd0 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisHubLifetimeManager.cs @@ -0,0 +1,593 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis +{ + public class RedisHubLifetimeManager : HubLifetimeManager, IDisposable where THub : Hub + { + private readonly HubConnectionStore _connections = new HubConnectionStore(); + private readonly RedisSubscriptionManager _groups = new RedisSubscriptionManager(); + private readonly RedisSubscriptionManager _users = new RedisSubscriptionManager(); + private IConnectionMultiplexer _redisServerConnection; + private ISubscriber _bus; + private readonly ILogger _logger; + private readonly RedisOptions _options; + private readonly RedisChannels _channels; + private readonly string _serverName = GenerateServerName(); + private readonly RedisProtocol _protocol; + private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(1); + + private readonly AckHandler _ackHandler; + private int _internalId; + + public RedisHubLifetimeManager(ILogger> logger, + IOptions options, + IHubProtocolResolver hubProtocolResolver) + { + _logger = logger; + _options = options.Value; + _ackHandler = new AckHandler(); + _channels = new RedisChannels(typeof(THub).FullName); + _protocol = new RedisProtocol(hubProtocolResolver.AllProtocols); + + RedisLog.ConnectingToEndpoints(_logger, options.Value.Configuration.EndPoints, _serverName); + _ = EnsureRedisServerConnection(); + } + + public override async Task OnConnectedAsync(HubConnectionContext connection) + { + await EnsureRedisServerConnection(); + var feature = new RedisFeature(); + connection.Features.Set(feature); + + var connectionTask = Task.CompletedTask; + var userTask = Task.CompletedTask; + + _connections.Add(connection); + + connectionTask = SubscribeToConnection(connection); + + if (!string.IsNullOrEmpty(connection.UserIdentifier)) + { + userTask = SubscribeToUser(connection); + } + + await Task.WhenAll(connectionTask, userTask); + } + + public override Task OnDisconnectedAsync(HubConnectionContext connection) + { + _connections.Remove(connection); + + var tasks = new List(); + + var connectionChannel = _channels.Connection(connection.ConnectionId); + RedisLog.Unsubscribe(_logger, connectionChannel); + tasks.Add(_bus.UnsubscribeAsync(connectionChannel)); + + var feature = connection.Features.Get(); + var groupNames = feature.Groups; + + if (groupNames != null) + { + // Copy the groups to an array here because they get removed from this collection + // in RemoveFromGroupAsync + foreach (var group in groupNames.ToArray()) + { + // Use RemoveGroupAsyncCore because the connection is local and we don't want to + // accidentally go to other servers with our remove request. + tasks.Add(RemoveGroupAsyncCore(connection, group)); + } + } + + if (!string.IsNullOrEmpty(connection.UserIdentifier)) + { + tasks.Add(RemoveUserAsync(connection)); + } + + return Task.WhenAll(tasks); + } + + public override Task SendAllAsync(string methodName, object[] args, CancellationToken cancellationToken = default) + { + var message = _protocol.WriteInvocation(methodName, args); + return PublishAsync(_channels.All, message); + } + + public override Task SendAllExceptAsync(string methodName, object[] args, IReadOnlyList excludedConnectionIds, CancellationToken cancellationToken = default) + { + var message = _protocol.WriteInvocation(methodName, args, excludedConnectionIds); + return PublishAsync(_channels.All, message); + } + + public override Task SendConnectionAsync(string connectionId, string methodName, object[] args, CancellationToken cancellationToken = default) + { + if (connectionId == null) + { + throw new ArgumentNullException(nameof(connectionId)); + } + + // If the connection is local we can skip sending the message through the bus since we require sticky connections. + // This also saves serializing and deserializing the message! + var connection = _connections[connectionId]; + if (connection != null) + { + return connection.WriteAsync(new InvocationMessage(methodName, args)).AsTask(); + } + + var message = _protocol.WriteInvocation(methodName, args); + return PublishAsync(_channels.Connection(connectionId), message); + } + + public override Task SendGroupAsync(string groupName, string methodName, object[] args, CancellationToken cancellationToken = default) + { + if (groupName == null) + { + throw new ArgumentNullException(nameof(groupName)); + } + + var message = _protocol.WriteInvocation(methodName, args); + return PublishAsync(_channels.Group(groupName), message); + } + + public override Task SendGroupExceptAsync(string groupName, string methodName, object[] args, IReadOnlyList excludedConnectionIds, CancellationToken cancellationToken = default) + { + if (groupName == null) + { + throw new ArgumentNullException(nameof(groupName)); + } + + var message = _protocol.WriteInvocation(methodName, args, excludedConnectionIds); + return PublishAsync(_channels.Group(groupName), message); + } + + public override Task SendUserAsync(string userId, string methodName, object[] args, CancellationToken cancellationToken = default) + { + var message = _protocol.WriteInvocation(methodName, args); + return PublishAsync(_channels.User(userId), message); + } + + public override Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + if (connectionId == null) + { + throw new ArgumentNullException(nameof(connectionId)); + } + + if (groupName == null) + { + throw new ArgumentNullException(nameof(groupName)); + } + + var connection = _connections[connectionId]; + if (connection != null) + { + // short circuit if connection is on this server + return AddGroupAsyncCore(connection, groupName); + } + + return SendGroupActionAndWaitForAck(connectionId, groupName, GroupAction.Add); + } + + public override Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + if (connectionId == null) + { + throw new ArgumentNullException(nameof(connectionId)); + } + + if (groupName == null) + { + throw new ArgumentNullException(nameof(groupName)); + } + + var connection = _connections[connectionId]; + if (connection != null) + { + // short circuit if connection is on this server + return RemoveGroupAsyncCore(connection, groupName); + } + + return SendGroupActionAndWaitForAck(connectionId, groupName, GroupAction.Remove); + } + + public override Task SendConnectionsAsync(IReadOnlyList connectionIds, string methodName, object[] args, CancellationToken cancellationToken = default) + { + if (connectionIds == null) + { + throw new ArgumentNullException(nameof(connectionIds)); + } + + var publishTasks = new List(connectionIds.Count); + var payload = _protocol.WriteInvocation(methodName, args); + + foreach (var connectionId in connectionIds) + { + publishTasks.Add(PublishAsync(_channels.Connection(connectionId), payload)); + } + + return Task.WhenAll(publishTasks); + } + + public override Task SendGroupsAsync(IReadOnlyList groupNames, string methodName, object[] args, CancellationToken cancellationToken = default) + { + if (groupNames == null) + { + throw new ArgumentNullException(nameof(groupNames)); + } + var publishTasks = new List(groupNames.Count); + var payload = _protocol.WriteInvocation(methodName, args); + + foreach (var groupName in groupNames) + { + if (!string.IsNullOrEmpty(groupName)) + { + publishTasks.Add(PublishAsync(_channels.Group(groupName), payload)); + } + } + + return Task.WhenAll(publishTasks); + } + + public override Task SendUsersAsync(IReadOnlyList userIds, string methodName, object[] args, CancellationToken cancellationToken = default) + { + if (userIds.Count > 0) + { + var payload = _protocol.WriteInvocation(methodName, args); + var publishTasks = new List(userIds.Count); + foreach (var userId in userIds) + { + if (!string.IsNullOrEmpty(userId)) + { + publishTasks.Add(PublishAsync(_channels.User(userId), payload)); + } + } + + return Task.WhenAll(publishTasks); + } + + return Task.CompletedTask; + } + + private async Task PublishAsync(string channel, byte[] payload) + { + await EnsureRedisServerConnection(); + RedisLog.PublishToChannel(_logger, channel); + await _bus.PublishAsync(channel, payload); + } + + private Task AddGroupAsyncCore(HubConnectionContext connection, string groupName) + { + var feature = connection.Features.Get(); + var groupNames = feature.Groups; + + lock (groupNames) + { + // Connection already in group + if (!groupNames.Add(groupName)) + { + return Task.CompletedTask; + } + } + + var groupChannel = _channels.Group(groupName); + return _groups.AddSubscriptionAsync(groupChannel, connection, SubscribeToGroupAsync); + } + + /// + /// This takes because we want to remove the connection from the + /// _connections list in OnDisconnectedAsync and still be able to remove groups with this method. + /// + private async Task RemoveGroupAsyncCore(HubConnectionContext connection, string groupName) + { + var groupChannel = _channels.Group(groupName); + + await _groups.RemoveSubscriptionAsync(groupChannel, connection, channelName => + { + RedisLog.Unsubscribe(_logger, channelName); + return _bus.UnsubscribeAsync(channelName); + }); + + var feature = connection.Features.Get(); + var groupNames = feature.Groups; + if (groupNames != null) + { + lock (groupNames) + { + groupNames.Remove(groupName); + } + } + } + + private async Task SendGroupActionAndWaitForAck(string connectionId, string groupName, GroupAction action) + { + var id = Interlocked.Increment(ref _internalId); + var ack = _ackHandler.CreateAck(id); + // Send Add/Remove Group to other servers and wait for an ack or timeout + var message = _protocol.WriteGroupCommand(new RedisGroupCommand(id, _serverName, action, groupName, connectionId)); + await PublishAsync(_channels.GroupManagement, message); + + await ack; + } + + private Task RemoveUserAsync(HubConnectionContext connection) + { + var userChannel = _channels.User(connection.UserIdentifier); + + return _users.RemoveSubscriptionAsync(userChannel, connection, channelName => + { + RedisLog.Unsubscribe(_logger, channelName); + return _bus.UnsubscribeAsync(channelName); + }); + } + + public void Dispose() + { + _bus?.UnsubscribeAll(); + _redisServerConnection?.Dispose(); + _ackHandler.Dispose(); + } + + private async Task SubscribeToAll() + { + RedisLog.Subscribing(_logger, _channels.All); + var channel = await _bus.SubscribeAsync(_channels.All); + channel.OnMessage(async channelMessage => + { + try + { + RedisLog.ReceivedFromChannel(_logger, _channels.All); + + var invocation = _protocol.ReadInvocation((byte[])channelMessage.Message); + + var tasks = new List(_connections.Count); + + foreach (var connection in _connections) + { + if (invocation.ExcludedConnectionIds == null || !invocation.ExcludedConnectionIds.Contains(connection.ConnectionId)) + { + tasks.Add(connection.WriteAsync(invocation.Message).AsTask()); + } + } + + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + RedisLog.FailedWritingMessage(_logger, ex); + } + }); + } + + private async Task SubscribeToGroupManagementChannel() + { + var channel = await _bus.SubscribeAsync(_channels.GroupManagement); + channel.OnMessage(async channelMessage => + { + try + { + var groupMessage = _protocol.ReadGroupCommand((byte[])channelMessage.Message); + + var connection = _connections[groupMessage.ConnectionId]; + if (connection == null) + { + // user not on this server + return; + } + + if (groupMessage.Action == GroupAction.Remove) + { + await RemoveGroupAsyncCore(connection, groupMessage.GroupName); + } + + if (groupMessage.Action == GroupAction.Add) + { + await AddGroupAsyncCore(connection, groupMessage.GroupName); + } + + // Send an ack to the server that sent the original command. + await PublishAsync(_channels.Ack(groupMessage.ServerName), _protocol.WriteAck(groupMessage.Id)); + } + catch (Exception ex) + { + RedisLog.InternalMessageFailed(_logger, ex); + } + }); + } + + private async Task SubscribeToAckChannel() + { + // Create server specific channel in order to send an ack to a single server + var channel = await _bus.SubscribeAsync(_channels.Ack(_serverName)); + channel.OnMessage(channelMessage => + { + var ackId = _protocol.ReadAck((byte[])channelMessage.Message); + + _ackHandler.TriggerAck(ackId); + }); + } + + private async Task SubscribeToConnection(HubConnectionContext connection) + { + var connectionChannel = _channels.Connection(connection.ConnectionId); + + RedisLog.Subscribing(_logger, connectionChannel); + var channel = await _bus.SubscribeAsync(connectionChannel); + channel.OnMessage(channelMessage => + { + var invocation = _protocol.ReadInvocation((byte[])channelMessage.Message); + return connection.WriteAsync(invocation.Message).AsTask(); + }); + } + + private Task SubscribeToUser(HubConnectionContext connection) + { + var userChannel = _channels.User(connection.UserIdentifier); + + return _users.AddSubscriptionAsync(userChannel, connection, async (channelName, subscriptions) => + { + RedisLog.Subscribing(_logger, channelName); + var channel = await _bus.SubscribeAsync(channelName); + channel.OnMessage(async channelMessage => + { + try + { + var invocation = _protocol.ReadInvocation((byte[])channelMessage.Message); + + var tasks = new List(); + foreach (var userConnection in subscriptions) + { + tasks.Add(userConnection.WriteAsync(invocation.Message).AsTask()); + } + + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + RedisLog.FailedWritingMessage(_logger, ex); + } + }); + }); + } + + private async Task SubscribeToGroupAsync(string groupChannel, HubConnectionStore groupConnections) + { + RedisLog.Subscribing(_logger, groupChannel); + var channel = await _bus.SubscribeAsync(groupChannel); + channel.OnMessage(async (channelMessage) => + { + try + { + var invocation = _protocol.ReadInvocation((byte[])channelMessage.Message); + + var tasks = new List(); + foreach (var groupConnection in groupConnections) + { + if (invocation.ExcludedConnectionIds?.Contains(groupConnection.ConnectionId) == true) + { + continue; + } + + tasks.Add(groupConnection.WriteAsync(invocation.Message).AsTask()); + } + + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + RedisLog.FailedWritingMessage(_logger, ex); + } + }); + } + + private async Task EnsureRedisServerConnection() + { + if (_redisServerConnection == null) + { + await _connectionLock.WaitAsync(); + try + { + if (_redisServerConnection == null) + { + var writer = new LoggerTextWriter(_logger); + _redisServerConnection = await _options.ConnectAsync(writer); + _bus = _redisServerConnection.GetSubscriber(); + + _redisServerConnection.ConnectionRestored += (_, e) => + { + // We use the subscription connection type + // Ignore messages from the interactive connection (avoids duplicates) + if (e.ConnectionType == ConnectionType.Interactive) + { + return; + } + + RedisLog.ConnectionRestored(_logger); + }; + + _redisServerConnection.ConnectionFailed += (_, e) => + { + // We use the subscription connection type + // Ignore messages from the interactive connection (avoids duplicates) + if (e.ConnectionType == ConnectionType.Interactive) + { + return; + } + + RedisLog.ConnectionFailed(_logger, e.Exception); + }; + + if (_redisServerConnection.IsConnected) + { + RedisLog.Connected(_logger); + } + else + { + RedisLog.NotConnected(_logger); + } + + await SubscribeToAll(); + await SubscribeToGroupManagementChannel(); + await SubscribeToAckChannel(); + } + } + finally + { + _connectionLock.Release(); + } + } + } + + private static string GenerateServerName() + { + // Use the machine name for convenient diagnostics, but add a guid to make it unique. + // Example: MyServerName_02db60e5fab243b890a847fa5c4dcb29 + return $"{Environment.MachineName}_{Guid.NewGuid():N}"; + } + + private class LoggerTextWriter : TextWriter + { + private readonly ILogger _logger; + + public LoggerTextWriter(ILogger logger) + { + _logger = logger; + } + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) + { + + } + + public override void WriteLine(string value) + { + RedisLog.ConnectionMultiplexerMessage(_logger, value); + } + } + + private interface IRedisFeature + { + HashSet Groups { get; } + } + + private class RedisFeature : IRedisFeature + { + public HashSet Groups { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisOptions.cs b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisOptions.cs new file mode 100644 index 0000000000..b34c7fb117 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.StackExchangeRedis/RedisOptions.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis +{ + /// + /// Options used to configure . + /// + public class RedisOptions + { + /// + /// Gets or sets configuration options exposed by StackExchange.Redis. + /// + public ConfigurationOptions Configuration { get; set; } = new ConfigurationOptions + { + // Enable reconnecting by default + AbortOnConnectFail = false + }; + + /// + /// Gets or sets the Redis connection factory. + /// + public Func> ConnectionFactory { get; set; } + + internal async Task ConnectAsync(TextWriter log) + { + // Factory is publically settable. Assigning to a local variable before null check for thread safety. + var factory = ConnectionFactory; + if (factory == null) + { + // REVIEW: Should we do this? + if (Configuration.EndPoints.Count == 0) + { + Configuration.EndPoints.Add(IPAddress.Loopback, 0); + Configuration.SetDefaultPorts(); + } + + return await ConnectionMultiplexer.ConnectAsync(Configuration, log); + } + + return await factory(log); + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Docker.cs b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Docker.cs index f60e557ae5..10f087ac6a 100644 --- a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Docker.cs +++ b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Docker.cs @@ -15,8 +15,8 @@ namespace Microsoft.AspNetCore.SignalR.Redis.Tests { private static readonly string _exeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; - private static readonly string _dockerContainerName = "redisTestContainer"; - private static readonly string _dockerMonitorContainerName = _dockerContainerName + "Monitor"; + private static readonly string _dockerContainerName = "redisTestContainer-1x"; + private static readonly string _dockerMonitorContainerName = _dockerContainerName + "Monitor-1x"; private static readonly Lazy _instance = new Lazy(Create); public static Docker Default => _instance.Value; @@ -82,7 +82,7 @@ namespace Microsoft.AspNetCore.SignalR.Redis.Tests // use static name 'redisTestContainer' so if the container doesn't get removed we don't keep adding more // use redis base docker image // 20 second timeout to allow redis image to be downloaded, should be a rare occurrence, only happening when a new version is released - RunProcessAndThrowIfFailed(_path, $"run --rm -p 6379:6379 --name {_dockerContainerName} -d redis", "redis", logger, TimeSpan.FromSeconds(20)); + RunProcessAndThrowIfFailed(_path, $"run --rm -p 6380:6379 --name {_dockerContainerName} -d redis", "redis", logger, TimeSpan.FromSeconds(20)); // inspect the redis docker image and extract the IPAddress. Necessary when running tests from inside a docker container, spinning up a new docker container for redis // outside the current container requires linking the networks (difficult to automate) or using the IP:Port combo @@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.SignalR.Redis.Tests output = output.Trim().Replace(Environment.NewLine, ""); // variable used by Startup.cs - Environment.SetEnvironmentVariable("REDIS_CONNECTION", $"{output}:6379"); + Environment.SetEnvironmentVariable("REDIS_CONNECTION-PREV", $"{output}:6379"); var (monitorProcess, monitorOutput) = RunProcess(_path, $"run -i --name {_dockerMonitorContainerName} --link {_dockerContainerName}:redis --rm redis redis-cli -h redis -p 6379", "redis monitor", logger); monitorProcess.StandardInput.WriteLine("MONITOR"); diff --git a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/RedisProtocolTests.cs b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/RedisProtocolTests.cs index b3ab182d5d..89b960df71 100644 --- a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/RedisProtocolTests.cs +++ b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/RedisProtocolTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System; using System.Buffers; using System.Collections.Generic; diff --git a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Startup.cs b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Startup.cs index f760a4e869..e99631513e 100644 --- a/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Startup.cs +++ b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/Startup.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.SignalR.Redis.Tests { // We start the servers before starting redis so we want to time them out ASAP options.Configuration.ConnectTimeout = 1; - options.Configuration.EndPoints.Add(Environment.GetEnvironmentVariable("REDIS_CONNECTION")); + options.Configuration.EndPoints.Add(Environment.GetEnvironmentVariable("REDIS_CONNECTION-PREV")); }); services.AddSingleton(); diff --git a/test/Microsoft.AspNetCore.SignalR.Tests.Utils/TestConnectionMultiplexer.cs b/test/Microsoft.AspNetCore.SignalR.Redis.Tests/TestConnectionMultiplexer.cs similarity index 100% rename from test/Microsoft.AspNetCore.SignalR.Tests.Utils/TestConnectionMultiplexer.cs rename to test/Microsoft.AspNetCore.SignalR.Redis.Tests/TestConnectionMultiplexer.cs diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Docker.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Docker.cs new file mode 100644 index 0000000000..d8722505c2 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Docker.cs @@ -0,0 +1,192 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class Docker + { + private static readonly string _exeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; + + private static readonly string _dockerContainerName = "redisTestContainer"; + private static readonly string _dockerMonitorContainerName = _dockerContainerName + "Monitor"; + private static readonly Lazy _instance = new Lazy(Create); + + public static Docker Default => _instance.Value; + + private readonly string _path; + + public Docker(string path) + { + _path = path; + } + + private static Docker Create() + { + var location = GetDockerLocation(); + if (location == null) + { + return null; + } + + var docker = new Docker(location); + + docker.RunCommand("info --format '{{.OSType}}'", "docker info", out var output); + + if (!string.Equals(output.Trim('\'', '"', '\r', '\n', ' '), "linux")) + { + Console.WriteLine($"'docker info' output: {output}"); + return null; + } + + return docker; + } + + private static string GetDockerLocation() + { + // OSX + Docker + Redis don't play well together for some reason. We already have these tests covered on Linux and Windows + // So we are happy ignoring them on OSX + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return null; + } + + foreach (var dir in Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator)) + { + var candidate = Path.Combine(dir, "docker" + _exeSuffix); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + public void Start(ILogger logger) + { + logger.LogInformation("Starting docker container"); + + // stop container if there is one, could be from a previous test run, ignore failures + RunProcessAndWait(_path, $"stop {_dockerMonitorContainerName}", "docker stop", logger, TimeSpan.FromSeconds(15), out var _); + RunProcessAndWait(_path, $"stop {_dockerContainerName}", "docker stop", logger, TimeSpan.FromSeconds(15), out var output); + + // create and run docker container, remove automatically when stopped, map 6379 from the container to 6379 localhost + // use static name 'redisTestContainer' so if the container doesn't get removed we don't keep adding more + // use redis base docker image + // 20 second timeout to allow redis image to be downloaded, should be a rare occurrence, only happening when a new version is released + RunProcessAndThrowIfFailed(_path, $"run --rm -p 6379:6379 --name {_dockerContainerName} -d redis", "redis", logger, TimeSpan.FromSeconds(20)); + + // inspect the redis docker image and extract the IPAddress. Necessary when running tests from inside a docker container, spinning up a new docker container for redis + // outside the current container requires linking the networks (difficult to automate) or using the IP:Port combo + RunProcessAndWait(_path, "inspect --format=\"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}\" " + _dockerContainerName, "docker ipaddress", logger, TimeSpan.FromSeconds(5), out output); + output = output.Trim().Replace(Environment.NewLine, ""); + + // variable used by Startup.cs + Environment.SetEnvironmentVariable("REDIS_CONNECTION", $"{output}:6379"); + + var (monitorProcess, monitorOutput) = RunProcess(_path, $"run -i --name {_dockerMonitorContainerName} --link {_dockerContainerName}:redis --rm redis redis-cli -h redis -p 6379", "redis monitor", logger); + monitorProcess.StandardInput.WriteLine("MONITOR"); + monitorProcess.StandardInput.Flush(); + } + + public void Stop(ILogger logger) + { + // Get logs from Redis container before stopping the container + RunProcessAndThrowIfFailed(_path, $"logs {_dockerContainerName}", "docker logs", logger, TimeSpan.FromSeconds(5)); + + logger.LogInformation("Stopping docker container"); + RunProcessAndWait(_path, $"stop {_dockerMonitorContainerName}", "docker stop", logger, TimeSpan.FromSeconds(15), out var _); + RunProcessAndWait(_path, $"stop {_dockerContainerName}", "docker stop", logger, TimeSpan.FromSeconds(15), out var _); + } + + public int RunCommand(string commandAndArguments, string prefix, out string output) => + RunCommand(commandAndArguments, prefix, NullLogger.Instance, out output); + + public int RunCommand(string commandAndArguments, string prefix, ILogger logger, out string output) + { + return RunProcessAndWait(_path, commandAndArguments, prefix, logger, TimeSpan.FromSeconds(5), out output); + } + + private static void RunProcessAndThrowIfFailed(string fileName, string arguments, string prefix, ILogger logger, TimeSpan timeout) + { + var exitCode = RunProcessAndWait(fileName, arguments, prefix, logger, timeout, out var output); + + if (exitCode != 0) + { + throw new Exception($"Command '{fileName} {arguments}' failed with exit code '{exitCode}'. Output:{Environment.NewLine}{output}"); + } + } + + private static int RunProcessAndWait(string fileName, string arguments, string prefix, ILogger logger, TimeSpan timeout, out string output) + { + var (process, lines) = RunProcess(fileName, arguments, prefix, logger); + + if (!process.WaitForExit((int)timeout.TotalMilliseconds)) + { + process.Close(); + logger.LogError("Closing process '{processName}' because it is running longer than the configured timeout.", fileName); + } + + // Need to WaitForExit without a timeout to guarantee the output stream has written everything + process.WaitForExit(); + + output = string.Join(Environment.NewLine, lines); + + return process.ExitCode; + } + + private static (Process, ConcurrentQueue) RunProcess(string fileName, string arguments, string prefix, ILogger logger) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }, + EnableRaisingEvents = true + }; + + var exitCode = 0; + var lines = new ConcurrentQueue(); + process.Exited += (_, __) => exitCode = process.ExitCode; + process.OutputDataReceived += (_, a) => + { + LogIfNotNull(logger.LogInformation, $"'{prefix}' stdout: {{0}}", a.Data); + lines.Enqueue(a.Data); + }; + process.ErrorDataReceived += (_, a) => + { + LogIfNotNull(logger.LogError, $"'{prefix}' stderr: {{0}}", a.Data); + lines.Enqueue(a.Data); + }; + + process.Start(); + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return (process, lines); + } + + private static void LogIfNotNull(Action logger, string message, string data) + { + if (!string.IsNullOrEmpty(data)) + { + logger(message, new[] { data }); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/EchoHub.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/EchoHub.cs new file mode 100644 index 0000000000..bfde399d32 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/EchoHub.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class EchoHub : Hub + { + public string Echo(string message) + { + return message; + } + + public Task EchoGroup(string groupName, string message) + { + return Clients.Group(groupName).SendAsync("Echo", message); + } + + public Task EchoUser(string userName, string message) + { + return Clients.User(userName).SendAsync("Echo", message); + } + + public Task AddSelfToGroup(string groupName) + { + return Groups.AddToGroupAsync(Context.ConnectionId, groupName); + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests.csproj b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests.csproj new file mode 100644 index 0000000000..ef017f8ba8 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests.csproj @@ -0,0 +1,27 @@ + + + + $(StandardTestTfms) + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisDependencyInjectionExtensionsTests.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisDependencyInjectionExtensionsTests.cs new file mode 100644 index 0000000000..14b4ca1026 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisDependencyInjectionExtensionsTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class RedisDependencyInjectionExtensionsTests + { + // No need to go too deep with these tests, or we're just testing StackExchange.Redis again :). It's the one doing the parsing. + [Theory] + [InlineData("testredis.example.com", "testredis.example.com", 0, null, false)] + [InlineData("testredis.example.com:6380,ssl=True", "testredis.example.com", 6380, null, true)] + [InlineData("testredis.example.com:6380,password=hunter2,ssl=True", "testredis.example.com", 6380, "hunter2", true)] + public void AddRedisWithConnectionStringProperlyParsesOptions(string connectionString, string host, int port, string password, bool useSsl) + { + var services = new ServiceCollection(); + services.AddSignalR().AddStackExchangeRedis(connectionString); + var provider = services.BuildServiceProvider(); + + var options = provider.GetService>(); + Assert.NotNull(options.Value); + Assert.NotNull(options.Value.Configuration); + Assert.Equal(password, options.Value.Configuration.Password); + Assert.Collection(options.Value.Configuration.EndPoints, + endpoint => + { + var dnsEndpoint = Assert.IsType(endpoint); + Assert.Equal(host, dnsEndpoint.Host); + Assert.Equal(port, dnsEndpoint.Port); + }); + Assert.Equal(useSsl, options.Value.Configuration.Ssl); + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisEndToEnd.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisEndToEnd.cs new file mode 100644 index 0000000000..50eb0dfb98 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisEndToEnd.cs @@ -0,0 +1,198 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.AspNetCore.SignalR.Tests; +using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + // Disable running server tests in parallel so server logs can accurately be captured per test + [CollectionDefinition(Name, DisableParallelization = true)] + public class RedisEndToEndTestsCollection : ICollectionFixture> + { + public const string Name = nameof(RedisEndToEndTestsCollection); + } + + [Collection(RedisEndToEndTestsCollection.Name)] + public class RedisEndToEndTests : VerifiableLoggedTest + { + private readonly RedisServerFixture _serverFixture; + + public RedisEndToEndTests(RedisServerFixture serverFixture, ITestOutputHelper output) : base(output) + { + if (serverFixture == null) + { + throw new ArgumentNullException(nameof(serverFixture)); + } + + _serverFixture = serverFixture; + } + + [ConditionalTheory] + [SkipIfDockerNotPresent] + [MemberData(nameof(TransportTypesAndProtocolTypes))] + public async Task HubConnectionCanSendAndReceiveMessages(HttpTransportType transportType, string protocolName) + { + using (StartVerifiableLog(out var loggerFactory, testName: + $"{nameof(HubConnectionCanSendAndReceiveMessages)}_{transportType.ToString()}_{protocolName}")) + { + var protocol = HubProtocolHelpers.GetHubProtocol(protocolName); + + var connection = CreateConnection(_serverFixture.FirstServer.Url + "/echo", transportType, protocol, loggerFactory); + + await connection.StartAsync().OrTimeout(); + var str = await connection.InvokeAsync("Echo", "Hello, World!").OrTimeout(); + + Assert.Equal("Hello, World!", str); + + await connection.DisposeAsync().OrTimeout(); + } + } + + [ConditionalTheory] + [SkipIfDockerNotPresent] + [MemberData(nameof(TransportTypesAndProtocolTypes))] + public async Task HubConnectionCanSendAndReceiveGroupMessages(HttpTransportType transportType, string protocolName) + { + using (StartVerifiableLog(out var loggerFactory, testName: + $"{nameof(HubConnectionCanSendAndReceiveGroupMessages)}_{transportType.ToString()}_{protocolName}")) + { + var protocol = HubProtocolHelpers.GetHubProtocol(protocolName); + + var connection = CreateConnection(_serverFixture.FirstServer.Url + "/echo", transportType, protocol, loggerFactory); + var secondConnection = CreateConnection(_serverFixture.SecondServer.Url + "/echo", transportType, protocol, loggerFactory); + + var tcs = new TaskCompletionSource(); + connection.On("Echo", message => tcs.TrySetResult(message)); + var tcs2 = new TaskCompletionSource(); + secondConnection.On("Echo", message => tcs2.TrySetResult(message)); + + var groupName = $"TestGroup_{transportType}_{protocolName}_{Guid.NewGuid()}"; + + await secondConnection.StartAsync().OrTimeout(); + await connection.StartAsync().OrTimeout(); + await connection.InvokeAsync("AddSelfToGroup", groupName).OrTimeout(); + await secondConnection.InvokeAsync("AddSelfToGroup", groupName).OrTimeout(); + await connection.InvokeAsync("EchoGroup", groupName, "Hello, World!").OrTimeout(); + + Assert.Equal("Hello, World!", await tcs.Task.OrTimeout()); + Assert.Equal("Hello, World!", await tcs2.Task.OrTimeout()); + + await connection.DisposeAsync().OrTimeout(); + } + } + + [ConditionalTheory(Skip= "https://github.com/aspnet/SignalR/issues/3058")] + [SkipIfDockerNotPresent] + [MemberData(nameof(TransportTypesAndProtocolTypes))] + public async Task CanSendAndReceiveUserMessagesFromMultipleConnectionsWithSameUser(HttpTransportType transportType, string protocolName) + { + using (StartVerifiableLog(out var loggerFactory, testName: + $"{nameof(CanSendAndReceiveUserMessagesFromMultipleConnectionsWithSameUser)}_{transportType.ToString()}_{protocolName}")) + { + var protocol = HubProtocolHelpers.GetHubProtocol(protocolName); + + var connection = CreateConnection(_serverFixture.FirstServer.Url + "/echo", transportType, protocol, loggerFactory, userName: "userA"); + var secondConnection = CreateConnection(_serverFixture.SecondServer.Url + "/echo", transportType, protocol, loggerFactory, userName: "userA"); + + var tcs = new TaskCompletionSource(); + connection.On("Echo", message => tcs.TrySetResult(message)); + var tcs2 = new TaskCompletionSource(); + secondConnection.On("Echo", message => tcs2.TrySetResult(message)); + + await secondConnection.StartAsync().OrTimeout(); + await connection.StartAsync().OrTimeout(); + await connection.InvokeAsync("EchoUser", "userA", "Hello, World!").OrTimeout(); + + Assert.Equal("Hello, World!", await tcs.Task.OrTimeout()); + Assert.Equal("Hello, World!", await tcs2.Task.OrTimeout()); + + await connection.DisposeAsync().OrTimeout(); + await secondConnection.DisposeAsync().OrTimeout(); + } + } + + [ConditionalTheory] + [SkipIfDockerNotPresent] + [MemberData(nameof(TransportTypesAndProtocolTypes))] + public async Task CanSendAndReceiveUserMessagesWhenOneConnectionWithUserDisconnects(HttpTransportType transportType, string protocolName) + { + // Regression test: + // When multiple connections from the same user were connected and one left, it used to unsubscribe from the user channel + // Now we keep track of users connections and only unsubscribe when no users are listening + using (StartVerifiableLog(out var loggerFactory, testName: + $"{nameof(CanSendAndReceiveUserMessagesWhenOneConnectionWithUserDisconnects)}_{transportType.ToString()}_{protocolName}")) + { + var protocol = HubProtocolHelpers.GetHubProtocol(protocolName); + + var firstConnection = CreateConnection(_serverFixture.FirstServer.Url + "/echo", transportType, protocol, loggerFactory, userName: "userA"); + var secondConnection = CreateConnection(_serverFixture.SecondServer.Url + "/echo", transportType, protocol, loggerFactory, userName: "userA"); + + var tcs = new TaskCompletionSource(); + firstConnection.On("Echo", message => tcs.TrySetResult(message)); + + await secondConnection.StartAsync().OrTimeout(); + await firstConnection.StartAsync().OrTimeout(); + await secondConnection.DisposeAsync().OrTimeout(); + await firstConnection.InvokeAsync("EchoUser", "userA", "Hello, World!").OrTimeout(); + + Assert.Equal("Hello, World!", await tcs.Task.OrTimeout()); + + await firstConnection.DisposeAsync().OrTimeout(); + } + } + + private static HubConnection CreateConnection(string url, HttpTransportType transportType, IHubProtocol protocol, ILoggerFactory loggerFactory, string userName = null) + { + var hubConnectionBuilder = new HubConnectionBuilder() + .WithLoggerFactory(loggerFactory) + .WithUrl(url, transportType, httpConnectionOptions => + { + if (!string.IsNullOrEmpty(userName)) + { + httpConnectionOptions.Headers["UserName"] = userName; + } + }); + + hubConnectionBuilder.Services.AddSingleton(protocol); + + return hubConnectionBuilder.Build(); + } + + private static IEnumerable TransportTypes() + { + if (TestHelpers.IsWebSocketsSupported()) + { + yield return HttpTransportType.WebSockets; + } + yield return HttpTransportType.ServerSentEvents; + yield return HttpTransportType.LongPolling; + } + + public static IEnumerable TransportTypesAndProtocolTypes + { + get + { + foreach (var transport in TransportTypes()) + { + yield return new object[] { transport, "json" }; + + if (transport != HttpTransportType.ServerSentEvents) + { + yield return new object[] { transport, "messagepack" }; + } + } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisHubLifetimeManagerTests.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisHubLifetimeManagerTests.cs new file mode 100644 index 0000000000..c9137975b5 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisHubLifetimeManagerTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Internal; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.AspNetCore.SignalR.Tests; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.SignalR.Specification.Tests; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + // Add ScaleoutHubLifetimeManagerTests back after https://github.com/aspnet/SignalR/issues/3088 + public class RedisHubLifetimeManagerTests + { + public class TestObject + { + public string TestProperty { get; set; } + } + + private RedisHubLifetimeManager CreateLifetimeManager(TestRedisServer server, MessagePackHubProtocolOptions messagePackOptions = null, JsonHubProtocolOptions jsonOptions = null) + { + var options = new RedisOptions() { ConnectionFactory = async (t) => await Task.FromResult(new TestConnectionMultiplexer(server)) }; + messagePackOptions = messagePackOptions ?? new MessagePackHubProtocolOptions(); + jsonOptions = jsonOptions ?? new JsonHubProtocolOptions(); + return new RedisHubLifetimeManager( + NullLogger>.Instance, + Options.Create(options), + new DefaultHubProtocolResolver(new IHubProtocol[] + { + new JsonHubProtocol(Options.Create(jsonOptions)), + new MessagePackHubProtocol(Options.Create(messagePackOptions)), + }, NullLogger.Instance)); + } + + [Fact(Skip = "https://github.com/aspnet/SignalR/issues/3088")] + public async Task CamelCasedJsonIsPreservedAcrossRedisBoundary() + { + var server = new TestRedisServer(); + + var messagePackOptions = new MessagePackHubProtocolOptions(); + + var jsonOptions = new JsonHubProtocolOptions(); + jsonOptions.PayloadSerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + + using (var client1 = new TestClient()) + using (var client2 = new TestClient()) + { + // The sending manager has serializer settings + var manager1 = CreateLifetimeManager(server, messagePackOptions, jsonOptions); + + // The receiving one doesn't matter because of how we serialize! + var manager2 = CreateLifetimeManager(server); + + var connection1 = HubConnectionContextUtils.Create(client1.Connection); + var connection2 = HubConnectionContextUtils.Create(client2.Connection); + + await manager1.OnConnectedAsync(connection1).OrTimeout(); + await manager2.OnConnectedAsync(connection2).OrTimeout(); + + await manager1.SendAllAsync("Hello", new object[] { new TestObject { TestProperty = "Foo" } }); + + var message = Assert.IsType(await client2.ReadAsync().OrTimeout()); + Assert.Equal("Hello", message.Target); + Assert.Collection( + message.Arguments, + arg0 => + { + var dict = Assert.IsType(arg0); + Assert.Collection(dict.Properties(), + prop => + { + Assert.Equal("testProperty", prop.Name); + Assert.Equal("Foo", prop.Value.Value()); + }); + }); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisProtocolTests.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisProtocolTests.cs new file mode 100644 index 0000000000..b8b3a0bca9 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisProtocolTests.cs @@ -0,0 +1,202 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.SignalR.Internal; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis.Internal; +using Microsoft.AspNetCore.SignalR.Tests; +using Xunit; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class RedisProtocolTests + { + private static Dictionary> _ackTestData = new[] + { + CreateTestData("Zero", 0, 0x91, 0x00), + CreateTestData("Fixnum", 42, 0x91, 0x2A), + CreateTestData("Uint8", 180, 0x91, 0xCC, 0xB4), + CreateTestData("Uint16", 384, 0x91, 0xCD, 0x01, 0x80), + CreateTestData("Uint32", 70_000, 0x91, 0xCE, 0x00, 0x01, 0x11, 0x70), + }.ToDictionary(t => t.Name); + + public static IEnumerable AckTestData = _ackTestData.Keys.Select(k => new object[] { k }); + + [Theory] + [MemberData(nameof(AckTestData))] + public void ParseAck(string testName) + { + var testData = _ackTestData[testName]; + var protocol = new RedisProtocol(Array.Empty()); + + var decoded = protocol.ReadAck(testData.Encoded); + + Assert.Equal(testData.Decoded, decoded); + } + + [Theory] + [MemberData(nameof(AckTestData))] + public void WriteAck(string testName) + { + var testData = _ackTestData[testName]; + var protocol = new RedisProtocol(Array.Empty()); + + var encoded = protocol.WriteAck(testData.Decoded); + + Assert.Equal(testData.Encoded, encoded); + } + + private static Dictionary> _groupCommandTestData = new[] + { + CreateTestData("GroupAdd", new RedisGroupCommand(42, "S", GroupAction.Add, "G", "C" ), 0x95, 0x2A, 0xA1, (byte)'S', 0x01, 0xA1, (byte)'G', 0xA1, (byte)'C'), + CreateTestData("GroupRemove", new RedisGroupCommand(42, "S", GroupAction.Remove, "G", "C" ), 0x95, 0x2A, 0xA1, (byte)'S', 0x02, 0xA1, (byte)'G', 0xA1, (byte)'C'), + }.ToDictionary(t => t.Name); + + public static IEnumerable GroupCommandTestData = _groupCommandTestData.Keys.Select(k => new object[] { k }); + + [Theory] + [MemberData(nameof(GroupCommandTestData))] + public void ParseGroupCommand(string testName) + { + var testData = _groupCommandTestData[testName]; + var protocol = new RedisProtocol(Array.Empty()); + + var decoded = protocol.ReadGroupCommand(testData.Encoded); + + Assert.Equal(testData.Decoded.Id, decoded.Id); + Assert.Equal(testData.Decoded.ServerName, decoded.ServerName); + Assert.Equal(testData.Decoded.Action, decoded.Action); + Assert.Equal(testData.Decoded.GroupName, decoded.GroupName); + Assert.Equal(testData.Decoded.ConnectionId, decoded.ConnectionId); + } + + [Theory] + [MemberData(nameof(GroupCommandTestData))] + public void WriteGroupCommand(string testName) + { + var testData = _groupCommandTestData[testName]; + var protocol = new RedisProtocol(Array.Empty()); + + var encoded = protocol.WriteGroupCommand(testData.Decoded); + + Assert.Equal(testData.Encoded, encoded); + } + + // The actual invocation message doesn't matter + private static InvocationMessage _testMessage = new InvocationMessage("target", Array.Empty()); + + // We use a func so we are guaranteed to get a new SerializedHubMessage for each test + private static Dictionary>> _invocationTestData = new[] + { + CreateTestData>( + "NoExcludedIds", + () => new RedisInvocation(new SerializedHubMessage(_testMessage), null), + 0x92, + 0x90, + 0x82, + 0xA2, (byte)'p', (byte)'1', + 0xC4, 0x01, 0x2A, + 0xA2, (byte)'p', (byte)'2', + 0xC4, 0x01, 0x2A), + CreateTestData>( + "OneExcludedId", + () => new RedisInvocation(new SerializedHubMessage(_testMessage), new [] { "a" }), + 0x92, + 0x91, + 0xA1, (byte)'a', + 0x82, + 0xA2, (byte)'p', (byte)'1', + 0xC4, 0x01, 0x2A, + 0xA2, (byte)'p', (byte)'2', + 0xC4, 0x01, 0x2A), + CreateTestData>( + "ManyExcludedIds", + () => new RedisInvocation(new SerializedHubMessage(_testMessage), new [] { "a", "b", "c", "d", "e", "f" }), + 0x92, + 0x96, + 0xA1, (byte)'a', + 0xA1, (byte)'b', + 0xA1, (byte)'c', + 0xA1, (byte)'d', + 0xA1, (byte)'e', + 0xA1, (byte)'f', + 0x82, + 0xA2, (byte)'p', (byte)'1', + 0xC4, 0x01, 0x2A, + 0xA2, (byte)'p', (byte)'2', + 0xC4, 0x01, 0x2A), + }.ToDictionary(t => t.Name); + + public static IEnumerable InvocationTestData = _invocationTestData.Keys.Select(k => new object[] { k }); + + [Theory] + [MemberData(nameof(InvocationTestData))] + public void ParseInvocation(string testName) + { + var testData = _invocationTestData[testName]; + var hubProtocols = new[] { new DummyHubProtocol("p1"), new DummyHubProtocol("p2") }; + var protocol = new RedisProtocol(hubProtocols); + + var expected = testData.Decoded(); + + var decoded = protocol.ReadInvocation(testData.Encoded); + + Assert.Equal(expected.ExcludedConnectionIds, decoded.ExcludedConnectionIds); + + // Verify the deserialized object has the necessary serialized forms + foreach (var hubProtocol in hubProtocols) + { + Assert.Equal( + expected.Message.GetSerializedMessage(hubProtocol).ToArray(), + decoded.Message.GetSerializedMessage(hubProtocol).ToArray()); + + var writtenMessages = hubProtocol.GetWrittenMessages(); + Assert.Collection(writtenMessages, + actualMessage => + { + var invocation = Assert.IsType(actualMessage); + Assert.Same(_testMessage.Target, invocation.Target); + Assert.Same(_testMessage.Arguments, invocation.Arguments); + }); + } + } + + [Theory] + [MemberData(nameof(InvocationTestData))] + public void WriteInvocation(string testName) + { + var testData = _invocationTestData[testName]; + var protocol = new RedisProtocol(new[] { new DummyHubProtocol("p1"), new DummyHubProtocol("p2") }); + + // Actual invocation doesn't matter because we're using a dummy hub protocol. + // But the dummy protocol will check that we gave it the test message to make sure everything flows through properly. + var expected = testData.Decoded(); + var encoded = protocol.WriteInvocation(_testMessage.Target, _testMessage.Arguments, expected.ExcludedConnectionIds); + + Assert.Equal(testData.Encoded, encoded); + } + + // Create ProtocolTestData using the Power of Type Inference(TM). + private static ProtocolTestData CreateTestData(string name, T decoded, params byte[] encoded) + => new ProtocolTestData(name, decoded, encoded); + + public class ProtocolTestData + { + public string Name { get; } + public T Decoded { get; } + public byte[] Encoded { get; } + + public ProtocolTestData(string name, T decoded, byte[] encoded) + { + Name = name; + Decoded = decoded; + Encoded = encoded; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisServerFixture.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisServerFixture.cs new file mode 100644 index 0000000000..0d0da42680 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/RedisServerFixture.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.SignalR.Tests; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class RedisServerFixture : IDisposable + where TStartup : class + { + public ServerFixture FirstServer { get; private set; } + public ServerFixture SecondServer { get; private set; } + + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly IDisposable _logToken; + + public RedisServerFixture() + { + // Docker is not available on the machine, tests using this fixture + // should be using SkipIfDockerNotPresentAttribute and will be skipped. + if (Docker.Default == null) + { + return; + } + + var testLog = AssemblyTestLog.ForAssembly(typeof(RedisServerFixture).Assembly); + _logToken = testLog.StartTestLog(null, $"{nameof(RedisServerFixture)}_{typeof(TStartup).Name}", out _loggerFactory, LogLevel.Trace, "RedisServerFixture"); + _logger = _loggerFactory.CreateLogger>(); + + Docker.Default.Start(_logger); + + FirstServer = StartServer(); + SecondServer = StartServer(); + } + + private ServerFixture StartServer() + { + try + { + return new ServerFixture(_loggerFactory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Server failed to start."); + throw; + } + } + + public void Dispose() + { + if (Docker.Default != null) + { + FirstServer.Dispose(); + SecondServer.Dispose(); + Docker.Default.Stop(_logger); + _logToken.Dispose(); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/SkipIfDockerNotPresentAttribute.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/SkipIfDockerNotPresentAttribute.cs new file mode 100644 index 0000000000..bf6c9ad91a --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/SkipIfDockerNotPresentAttribute.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Testing.xunit; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class SkipIfDockerNotPresentAttribute : Attribute, ITestCondition + { + public bool IsMet => CheckDocker(); + public string SkipReason { get; private set; } = "Docker is not available"; + + private bool CheckDocker() + { + if (Docker.Default != null) + { + // Docker is present, but is it working? + if (Docker.Default.RunCommand("ps", "docker ps", out var output) != 0) + { + SkipReason = $"Failed to invoke test command 'docker ps'. Output: {output}"; + } + else + { + // We have a docker + return true; + } + } + else + { + SkipReason = "Docker is not installed on the host machine."; + } + + // If we get here, we don't have a docker + return false; + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Startup.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Startup.cs new file mode 100644 index 0000000000..56bf354306 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/Startup.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddSignalR(options => + { + options.EnableDetailedErrors = true; + }) + .AddMessagePackProtocol() + .AddStackExchangeRedis(options => + { + // We start the servers before starting redis so we want to time them out ASAP + options.Configuration.ConnectTimeout = 1; + options.Configuration.EndPoints.Add(Environment.GetEnvironmentVariable("REDIS_CONNECTION")); + }); + + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + app.UseSignalR(options => options.MapHub("/echo")); + } + + private class UserNameIdProvider : IUserIdProvider + { + public string GetUserId(HubConnectionContext connection) + { + // This is an AWFUL way to authenticate users! We're just using it for test purposes. + var userNameHeader = connection.GetHttpContext().Request.Headers["UserName"]; + if (!StringValues.IsNullOrEmpty(userNameHeader)) + { + return userNameHeader; + } + + return null; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/TestConnectionMultiplexer.cs b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/TestConnectionMultiplexer.cs new file mode 100644 index 0000000000..664316c469 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.StackExchangeRedis.Tests/TestConnectionMultiplexer.cs @@ -0,0 +1,376 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis; +using StackExchange.Redis.Profiling; + +namespace Microsoft.AspNetCore.SignalR.Tests +{ + public class TestConnectionMultiplexer : IConnectionMultiplexer + { + public string ClientName => throw new NotImplementedException(); + + public string Configuration => throw new NotImplementedException(); + + public int TimeoutMilliseconds => throw new NotImplementedException(); + + public long OperationCount => throw new NotImplementedException(); + + public bool PreserveAsyncOrder { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public bool IsConnected => true; + + public bool IncludeDetailInExceptions { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public int StormLogThreshold { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public bool IsConnecting => throw new NotImplementedException(); + + public event EventHandler ErrorMessage + { + add { } + remove { } + } + + public event EventHandler ConnectionFailed + { + add { } + remove { } + } + + public event EventHandler InternalError + { + add { } + remove { } + } + + public event EventHandler ConnectionRestored + { + add { } + remove { } + } + + public event EventHandler ConfigurationChanged + { + add { } + remove { } + } + + public event EventHandler ConfigurationChangedBroadcast + { + add { } + remove { } + } + + public event EventHandler HashSlotMoved + { + add { } + remove { } + } + + private readonly ISubscriber _subscriber; + + public TestConnectionMultiplexer(TestRedisServer server) + { + _subscriber = new TestSubscriber(server); + } + + public void BeginProfiling(object forContext) + { + throw new NotImplementedException(); + } + + public void Close(bool allowCommandsToComplete = true) + { + throw new NotImplementedException(); + } + + public Task CloseAsync(bool allowCommandsToComplete = true) + { + throw new NotImplementedException(); + } + + public bool Configure(TextWriter log = null) + { + throw new NotImplementedException(); + } + + public Task ConfigureAsync(TextWriter log = null) + { + throw new NotImplementedException(); + } + + public void Dispose() + { + throw new NotImplementedException(); + } + + public ProfiledCommandEnumerable FinishProfiling(object forContext, bool allowCleanupSweep = true) + { + throw new NotImplementedException(); + } + + public ServerCounters GetCounters() + { + throw new NotImplementedException(); + } + + public IDatabase GetDatabase(int db = -1, object asyncState = null) + { + throw new NotImplementedException(); + } + + public EndPoint[] GetEndPoints(bool configuredOnly = false) + { + throw new NotImplementedException(); + } + + public IServer GetServer(string host, int port, object asyncState = null) + { + throw new NotImplementedException(); + } + + public IServer GetServer(string hostAndPort, object asyncState = null) + { + throw new NotImplementedException(); + } + + public IServer GetServer(IPAddress host, int port) + { + throw new NotImplementedException(); + } + + public IServer GetServer(EndPoint endpoint, object asyncState = null) + { + throw new NotImplementedException(); + } + + public string GetStatus() + { + throw new NotImplementedException(); + } + + public void GetStatus(TextWriter log) + { + throw new NotImplementedException(); + } + + public string GetStormLog() + { + throw new NotImplementedException(); + } + + public ISubscriber GetSubscriber(object asyncState = null) + { + return _subscriber; + } + + public int HashSlot(RedisKey key) + { + throw new NotImplementedException(); + } + + public long PublishReconfigure(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public void ResetStormLog() + { + throw new NotImplementedException(); + } + + public void Wait(Task task) + { + throw new NotImplementedException(); + } + + public T Wait(Task task) + { + throw new NotImplementedException(); + } + + public void WaitAll(params Task[] tasks) + { + throw new NotImplementedException(); + } + + public void RegisterProfiler(Func profilingSessionProvider) + { + throw new NotImplementedException(); + } + + public int GetHashSlot(RedisKey key) + { + throw new NotImplementedException(); + } + + public void ExportConfiguration(Stream destination, ExportOptions options = (ExportOptions)(-1)) + { + throw new NotImplementedException(); + } + } + + public class TestRedisServer + { + private readonly ConcurrentDictionary>> _subscriptions = + new ConcurrentDictionary>>(); + + public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + { + if (_subscriptions.TryGetValue(channel, out var handlers)) + { + foreach (var handler in handlers) + { + handler(channel, message); + } + } + + return handlers != null ? handlers.Count : 0; + } + + public void Subscribe(RedisChannel channel, Action handler, CommandFlags flags = CommandFlags.None) + { + _subscriptions.AddOrUpdate(channel, _ => new List> { handler }, (_, list) => + { + list.Add(handler); + return list; + }); + } + + public void Unsubscribe(RedisChannel channel, Action handler = null, CommandFlags flags = CommandFlags.None) + { + if (_subscriptions.TryGetValue(channel, out var list)) + { + list.Remove(handler); + } + } + } + + public class TestSubscriber : ISubscriber + { + private readonly TestRedisServer _server; + public ConnectionMultiplexer Multiplexer => throw new NotImplementedException(); + + IConnectionMultiplexer IRedisAsync.Multiplexer => throw new NotImplementedException(); + + public TestSubscriber(TestRedisServer server) + { + _server = server; + } + + public EndPoint IdentifyEndpoint(RedisChannel channel, CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public bool IsConnected(RedisChannel channel = default) + { + throw new NotImplementedException(); + } + + public TimeSpan Ping(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task PingAsync(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + { + return _server.Publish(channel, message, flags); + } + + public async Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + { + await Task.Yield(); + return Publish(channel, message, flags); + } + + public void Subscribe(RedisChannel channel, Action handler, CommandFlags flags = CommandFlags.None) + { + _server.Subscribe(channel, handler, flags); + } + + public Task SubscribeAsync(RedisChannel channel, Action handler, CommandFlags flags = CommandFlags.None) + { + Subscribe(channel, handler, flags); + return Task.CompletedTask; + } + + public EndPoint SubscribedEndpoint(RedisChannel channel) + { + throw new NotImplementedException(); + } + + public bool TryWait(Task task) + { + throw new NotImplementedException(); + } + + public void Unsubscribe(RedisChannel channel, Action handler = null, CommandFlags flags = CommandFlags.None) + { + _server.Unsubscribe(channel, handler, flags); + } + + public void UnsubscribeAll(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task UnsubscribeAllAsync(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task UnsubscribeAsync(RedisChannel channel, Action handler = null, CommandFlags flags = CommandFlags.None) + { + Unsubscribe(channel, handler, flags); + return Task.CompletedTask; + } + + public void Wait(Task task) + { + throw new NotImplementedException(); + } + + public T Wait(Task task) + { + throw new NotImplementedException(); + } + + public void WaitAll(params Task[] tasks) + { + throw new NotImplementedException(); + } + + public ChannelMessageQueue Subscribe(RedisChannel channel, CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task SubscribeAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) + { + var t = Subscribe(channel, flags); + return Task.FromResult(t); + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj b/test/Microsoft.AspNetCore.SignalR.Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj index b6884a9fdc..976041ab7c 100644 --- a/test/Microsoft.AspNetCore.SignalR.Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj +++ b/test/Microsoft.AspNetCore.SignalR.Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj @@ -22,7 +22,6 @@ - From 9049bf709c8aae566f20a798e70e009575491155 Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Wed, 10 Oct 2018 16:59:29 -0700 Subject: [PATCH 6/6] [Java] Cleanup exceptions (#3110) --- .../com/microsoft/signalr/HubConnection.java | 22 +-- .../com/microsoft/signalr/HubException.java | 2 +- .../com/microsoft/signalr/HubProtocol.java | 2 +- .../microsoft/signalr/InvocationBinder.java | 2 +- .../microsoft/signalr/JsonHubProtocol.java | 172 +++++++++--------- .../microsoft/signalr/NegotiateResponse.java | 102 ++++++----- .../microsoft/signalr/OnReceiveCallBack.java | 2 +- .../java/com/microsoft/signalr/Transport.java | 2 +- .../microsoft/signalr/WebSocketTransport.java | 2 +- .../microsoft/signalr/HubConnectionTest.java | 93 +++++----- .../signalr/JsonHubProtocolTest.java | 30 +-- .../com/microsoft/signalr/MockTransport.java | 10 +- .../signalr/NegotiateResponseTest.java | 8 +- .../signalr/WebSocketTransportTest.java | 2 +- .../com/microsoft/signalr/sample/Chat.java | 2 +- 15 files changed, 227 insertions(+), 226 deletions(-) diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java index c7cf0ae6a1..df17a9421f 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubConnection.java @@ -3,7 +3,6 @@ package com.microsoft.signalr; -import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.Date; @@ -197,12 +196,7 @@ public class HubConnection { if (response.getStatusCode() != 200) { throw new RuntimeException(String.format("Unexpected status code returned from negotiate: %d %s.", response.getStatusCode(), response.getStatusText())); } - NegotiateResponse negotiateResponse; - try { - negotiateResponse = new NegotiateResponse(response.getContent()); - } catch (IOException e) { - throw new RuntimeException(e); - } + NegotiateResponse negotiateResponse = new NegotiateResponse(response.getContent()); if (negotiateResponse.getError() != null) { throw new RuntimeException(negotiateResponse.getError()); @@ -409,18 +403,18 @@ public class HubConnection { * * @param method The name of the server method to invoke. * @param args The arguments to be passed to the method. - * @throws Exception If there was an error while sending. */ - public void send(String method, Object... args) throws Exception { + public void send(String method, Object... args) { if (hubConnectionState != HubConnectionState.CONNECTED) { - throw new HubException("The 'send' method cannot be called if the connection is not active"); + throw new RuntimeException("The 'send' method cannot be called if the connection is not active"); } InvocationMessage invocationMessage = new InvocationMessage(null, method, args); sendHubMessage(invocationMessage); } - public Single invoke(Class returnType, String method, Object... args) throws Exception { + @SuppressWarnings("unchecked") + public Single invoke(Class returnType, String method, Object... args) { String id = connectionState.getNextInvocationId(); InvocationMessage invocationMessage = new InvocationMessage(id, method, args); @@ -451,7 +445,7 @@ public class HubConnection { return Single.fromFuture(future); } - private void sendHubMessage(HubMessage message) throws Exception { + private void sendHubMessage(HubMessage message) { String serializedMessage = protocol.writeMessage(message); if (message.getMessageType() == HubMessageType.INVOCATION) { logger.log(LogLevel.Debug, "Sending %s message '%s'.", message.getMessageType().name(), ((InvocationMessage)message).getInvocationId()); @@ -777,7 +771,7 @@ public class HubConnection { } @Override - public List> getParameterTypes(String methodName) throws Exception { + public List> getParameterTypes(String methodName) { List handlers = connection.handlers.get(methodName); if (handlers == null) { logger.log(LogLevel.Warning, "Failed to find handler for '%s' method.", methodName); @@ -785,7 +779,7 @@ public class HubConnection { } if (handlers.isEmpty()) { - throw new Exception(String.format("There are no callbacks registered for the method '%s'.", methodName)); + throw new RuntimeException(String.format("There are no callbacks registered for the method '%s'.", methodName)); } return handlers.get(0).getClasses(); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java index a7a30a3d9b..a5f44433e4 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubException.java @@ -3,7 +3,7 @@ package com.microsoft.signalr; -public class HubException extends Exception { +public class HubException extends RuntimeException { private static final long serialVersionUID = -572019264269821519L; public HubException() { diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubProtocol.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubProtocol.java index 6667f1e862..072fa6b506 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/HubProtocol.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/HubProtocol.java @@ -16,7 +16,7 @@ interface HubProtocol { * @param message A string representation of one or more {@link HubMessage}s. * @return A list of {@link HubMessage}s. */ - HubMessage[] parseMessages(String message, InvocationBinder binder) throws Exception; + HubMessage[] parseMessages(String message, InvocationBinder binder); /** * Writes the specified {@link HubMessage} to a String. diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationBinder.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationBinder.java index 5ac9a9a6b8..3f3457f730 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationBinder.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/InvocationBinder.java @@ -7,5 +7,5 @@ import java.util.List; interface InvocationBinder { Class getReturnType(String invocationId); - List> getParameterTypes(String methodName) throws Exception; + List> getParameterTypes(String methodName); } \ No newline at end of file diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/JsonHubProtocol.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/JsonHubProtocol.java index 48545471d7..9922ca13e2 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/JsonHubProtocol.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/JsonHubProtocol.java @@ -36,105 +36,109 @@ class JsonHubProtocol implements HubProtocol { } @Override - public HubMessage[] parseMessages(String payload, InvocationBinder binder) throws Exception { + public HubMessage[] parseMessages(String payload, InvocationBinder binder) { if (payload != null && !payload.substring(payload.length() - 1).equals(RECORD_SEPARATOR)) { throw new RuntimeException("Message is incomplete."); } String[] messages = payload.split(RECORD_SEPARATOR); List hubMessages = new ArrayList<>(); - for (String str : messages) { - HubMessageType messageType = null; - String invocationId = null; - String target = null; - String error = null; - ArrayList arguments = null; - JsonArray argumentsToken = null; - Object result = null; - JsonElement resultToken = null; - JsonReader reader = new JsonReader(new StringReader(str)); - reader.beginObject(); + try { + for (String str : messages) { + HubMessageType messageType = null; + String invocationId = null; + String target = null; + String error = null; + ArrayList arguments = null; + JsonArray argumentsToken = null; + Object result = null; + JsonElement resultToken = null; + JsonReader reader = new JsonReader(new StringReader(str)); + reader.beginObject(); - do { - String name = reader.nextName(); - switch (name) { - case "type": - messageType = HubMessageType.values()[reader.nextInt() - 1]; - break; - case "invocationId": - invocationId = reader.nextString(); - break; - case "target": - target = reader.nextString(); - break; - case "error": - error = reader.nextString(); - break; - case "result": - if (invocationId == null) { - resultToken = jsonParser.parse(reader); - } else { - result = gson.fromJson(reader, binder.getReturnType(invocationId)); - } - break; - case "item": - reader.skipValue(); - break; - case "arguments": - if (target != null) { + do { + String name = reader.nextName(); + switch (name) { + case "type": + messageType = HubMessageType.values()[reader.nextInt() - 1]; + break; + case "invocationId": + invocationId = reader.nextString(); + break; + case "target": + target = reader.nextString(); + break; + case "error": + error = reader.nextString(); + break; + case "result": + if (invocationId == null) { + resultToken = jsonParser.parse(reader); + } else { + result = gson.fromJson(reader, binder.getReturnType(invocationId)); + } + break; + case "item": + reader.skipValue(); + break; + case "arguments": + if (target != null) { + List> types = binder.getParameterTypes(target); + arguments = bindArguments(reader, types); + } else { + argumentsToken = (JsonArray)jsonParser.parse(reader); + } + break; + case "headers": + throw new RuntimeException("Headers not implemented yet."); + default: + // Skip unknown property, allows new clients to still work with old protocols + reader.skipValue(); + break; + } + } while (reader.hasNext()); + + reader.endObject(); + reader.close(); + + switch (messageType) { + case INVOCATION: + if (argumentsToken != null) { List> types = binder.getParameterTypes(target); - arguments = bindArguments(reader, types); + arguments = bindArguments(argumentsToken, types); + } + if (arguments == null) { + hubMessages.add(new InvocationMessage(invocationId, target, new Object[0])); } else { - argumentsToken = (JsonArray)jsonParser.parse(reader); + hubMessages.add(new InvocationMessage(invocationId, target, arguments.toArray())); + } + break; + case COMPLETION: + if (resultToken != null) { + result = gson.fromJson(resultToken, binder.getReturnType(invocationId)); + } + hubMessages.add(new CompletionMessage(invocationId, result, error)); + break; + case STREAM_INVOCATION: + case STREAM_ITEM: + case CANCEL_INVOCATION: + throw new UnsupportedOperationException(String.format("The message type %s is not supported yet.", messageType)); + case PING: + hubMessages.add(PingMessage.getInstance()); + break; + case CLOSE: + if (error != null) { + hubMessages.add(new CloseMessage(error)); + } else { + hubMessages.add(new CloseMessage()); } break; - case "headers": - throw new HubException("Headers not implemented yet."); default: - // Skip unknown property, allows new clients to still work with old protocols - reader.skipValue(); break; } - } while (reader.hasNext()); - - reader.endObject(); - reader.close(); - - switch (messageType) { - case INVOCATION: - if (argumentsToken != null) { - List> types = binder.getParameterTypes(target); - arguments = bindArguments(argumentsToken, types); - } - if (arguments == null) { - hubMessages.add(new InvocationMessage(invocationId, target, new Object[0])); - } else { - hubMessages.add(new InvocationMessage(invocationId, target, arguments.toArray())); - } - break; - case COMPLETION: - if (resultToken != null) { - result = gson.fromJson(resultToken, binder.getReturnType(invocationId)); - } - hubMessages.add(new CompletionMessage(invocationId, result, error)); - break; - case STREAM_INVOCATION: - case STREAM_ITEM: - case CANCEL_INVOCATION: - throw new UnsupportedOperationException(String.format("The message type %s is not supported yet.", messageType)); - case PING: - hubMessages.add(PingMessage.getInstance()); - break; - case CLOSE: - if (error != null) { - hubMessages.add(new CloseMessage(error)); - } else { - hubMessages.add(new CloseMessage()); - } - break; - default: - break; } + } catch (IOException ex) { + throw new RuntimeException("Error reading JSON.", ex); } return hubMessages.toArray(new HubMessage[hubMessages.size()]); diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/NegotiateResponse.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/NegotiateResponse.java index 1e7ca333ef..23823faa9d 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/NegotiateResponse.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/NegotiateResponse.java @@ -17,60 +17,64 @@ class NegotiateResponse { private String accessToken; private String error; - public NegotiateResponse(String negotiatePayload) throws IOException { - JsonReader reader = new JsonReader(new StringReader(negotiatePayload)); - reader.beginObject(); + public NegotiateResponse(String negotiatePayload) { + try { + JsonReader reader = new JsonReader(new StringReader(negotiatePayload)); + reader.beginObject(); - do { - String name = reader.nextName(); - switch (name) { - case "error": - this.error = reader.nextString(); - break; - case "url": - this.redirectUrl = reader.nextString(); - break; - case "accessToken": - this.accessToken = reader.nextString(); - break; - case "availableTransports": - reader.beginArray(); - while (reader.hasNext()) { - reader.beginObject(); + do { + String name = reader.nextName(); + switch (name) { + case "error": + this.error = reader.nextString(); + break; + case "url": + this.redirectUrl = reader.nextString(); + break; + case "accessToken": + this.accessToken = reader.nextString(); + break; + case "availableTransports": + reader.beginArray(); while (reader.hasNext()) { - String transport = null; - String property = reader.nextName(); - switch (property) { - case "transport": - transport = reader.nextString(); - break; - case "transferFormats": - // transfer formats aren't supported currently - reader.skipValue(); - break; - default: - // Skip unknown property, allows new clients to still work with old protocols - reader.skipValue(); - break; + reader.beginObject(); + while (reader.hasNext()) { + String transport = null; + String property = reader.nextName(); + switch (property) { + case "transport": + transport = reader.nextString(); + break; + case "transferFormats": + // transfer formats aren't supported currently + reader.skipValue(); + break; + default: + // Skip unknown property, allows new clients to still work with old protocols + reader.skipValue(); + break; + } + this.availableTransports.add(transport); } - this.availableTransports.add(transport); + reader.endObject(); } - reader.endObject(); - } - reader.endArray(); - break; - case "connectionId": - this.connectionId = reader.nextString(); - break; - default: - // Skip unknown property, allows new clients to still work with old protocols - reader.skipValue(); - break; - } - } while (reader.hasNext()); + reader.endArray(); + break; + case "connectionId": + this.connectionId = reader.nextString(); + break; + default: + // Skip unknown property, allows new clients to still work with old protocols + reader.skipValue(); + break; + } + } while (reader.hasNext()); - reader.endObject(); - reader.close(); + reader.endObject(); + reader.close(); + } catch (IOException ex) { + throw new RuntimeException("Error reading NegotiateResponse", ex); + } } public String getConnectionId() { diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/OnReceiveCallBack.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/OnReceiveCallBack.java index 34c0793a09..71faede3f0 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/OnReceiveCallBack.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/OnReceiveCallBack.java @@ -4,5 +4,5 @@ package com.microsoft.signalr; interface OnReceiveCallBack { - void invoke(String message) throws Exception; + void invoke(String message); } diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/Transport.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/Transport.java index 7eadc933af..04b7085df5 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/Transport.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/Transport.java @@ -10,7 +10,7 @@ interface Transport { CompletableFuture start(String url); CompletableFuture send(String message); void setOnReceive(OnReceiveCallBack callback); - void onReceive(String message) throws Exception; + void onReceive(String message); void setOnClose(Consumer onCloseCallback); CompletableFuture stop(); } diff --git a/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java b/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java index 2024310d85..cdd450e13d 100644 --- a/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java +++ b/clients/java/signalr/src/main/java/com/microsoft/signalr/WebSocketTransport.java @@ -68,7 +68,7 @@ class WebSocketTransport implements Transport { } @Override - public void onReceive(String message) throws Exception { + public void onReceive(String message) { this.onReceiveCallBack.invoke(message); } diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java index ba11f88ce1..14417b2193 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/HubConnectionTest.java @@ -24,7 +24,7 @@ class HubConnectionTest { private static final String RECORD_SEPARATOR = "\u001e"; @Test - public void checkHubConnectionState() throws Exception { + public void checkHubConnectionState() { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -34,7 +34,7 @@ class HubConnectionTest { } @Test - public void transportCloseTriggersStopInHubConnection() throws Exception { + public void transportCloseTriggersStopInHubConnection() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.start().blockingAwait(1000, TimeUnit.MILLISECONDS); @@ -45,7 +45,7 @@ class HubConnectionTest { } @Test - public void transportCloseWithErrorTriggersStopInHubConnection() throws Exception { + public void transportCloseWithErrorTriggersStopInHubConnection() { MockTransport mockTransport = new MockTransport(); AtomicReference message = new AtomicReference<>(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -90,7 +90,7 @@ class HubConnectionTest { } @Test - public void hubConnectionClosesAfterCloseMessage() throws Exception { + public void hubConnectionClosesAfterCloseMessage() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -104,7 +104,7 @@ class HubConnectionTest { } @Test - public void invalidHandShakeResponse() throws Exception { + public void invalidHandShakeResponse() { MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -126,7 +126,7 @@ class HubConnectionTest { } @Test - public void registeringMultipleHandlersAndBothGetTriggered() throws Exception { + public void registeringMultipleHandlersAndBothGetTriggered() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -151,7 +151,7 @@ class HubConnectionTest { } @Test - public void removeHandlerByName() throws Exception { + public void removeHandlerByName() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -177,7 +177,7 @@ class HubConnectionTest { } @Test - public void addAndRemoveHandlerImmediately() throws Exception { + public void addAndRemoveHandlerImmediately() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -201,7 +201,7 @@ class HubConnectionTest { } @Test - public void removingMultipleHandlersWithOneCallToRemove() throws Exception { + public void removingMultipleHandlersWithOneCallToRemove() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -232,7 +232,7 @@ class HubConnectionTest { } @Test - public void removeHandlerWithUnsubscribe() throws Exception { + public void removeHandlerWithUnsubscribe() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -264,7 +264,7 @@ class HubConnectionTest { } @Test - public void unsubscribeTwice() throws Exception { + public void unsubscribeTwice() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -297,7 +297,7 @@ class HubConnectionTest { } @Test - public void removeSingleHandlerWithUnsubscribe() throws Exception { + public void removeSingleHandlerWithUnsubscribe() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -326,7 +326,7 @@ class HubConnectionTest { } @Test - public void addAndRemoveHandlerImmediatelyWithSubscribe() throws Exception { + public void addAndRemoveHandlerImmediatelyWithSubscribe() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -350,7 +350,7 @@ class HubConnectionTest { } @Test - public void registeringMultipleHandlersThatTakeParamsAndBothGetTriggered() throws Exception { + public void registeringMultipleHandlersThatTakeParamsAndBothGetTriggered() { AtomicReference value = new AtomicReference<>(0.0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -369,7 +369,7 @@ class HubConnectionTest { } @Test - public void invokeWaitsForCompletionMessage() throws Exception { + public void invokeWaitsForCompletionMessage() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -387,7 +387,7 @@ class HubConnectionTest { } @Test - public void multipleInvokesWaitForOwnCompletionMessage() throws Exception { + public void multipleInvokesWaitForOwnCompletionMessage() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -413,7 +413,7 @@ class HubConnectionTest { } @Test - public void invokeWorksForPrimitiveTypes() throws Exception { + public void invokeWorksForPrimitiveTypes() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -432,7 +432,7 @@ class HubConnectionTest { } @Test - public void completionMessageCanHaveError() throws Exception { + public void completionMessageCanHaveError() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -457,7 +457,7 @@ class HubConnectionTest { } @Test - public void stopCancelsActiveInvokes() throws Exception { + public void stopCancelsActiveInvokes() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -482,7 +482,7 @@ class HubConnectionTest { } @Test - public void sendWithNoParamsTriggersOnHandler() throws Exception { + public void sendWithNoParamsTriggersOnHandler() { AtomicReference value = new AtomicReference<>(0); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -500,7 +500,7 @@ class HubConnectionTest { } @Test - public void sendWithParamTriggersOnHandler() throws Exception { + public void sendWithParamTriggersOnHandler() { AtomicReference value = new AtomicReference<>(); MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -519,7 +519,7 @@ class HubConnectionTest { } @Test - public void sendWithTwoParamsTriggersOnHandler() throws Exception { + public void sendWithTwoParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); @@ -544,7 +544,7 @@ class HubConnectionTest { } @Test - public void sendWithThreeParamsTriggersOnHandler() throws Exception { + public void sendWithThreeParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -573,7 +573,7 @@ class HubConnectionTest { } @Test - public void sendWithFourParamsTriggersOnHandler() throws Exception { + public void sendWithFourParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -605,7 +605,7 @@ class HubConnectionTest { } @Test - public void sendWithFiveParamsTriggersOnHandler() throws Exception { + public void sendWithFiveParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -641,7 +641,7 @@ class HubConnectionTest { } @Test - public void sendWithSixParamsTriggersOnHandler() throws Exception { + public void sendWithSixParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -681,7 +681,7 @@ class HubConnectionTest { } @Test - public void sendWithSevenParamsTriggersOnHandler() throws Exception { + public void sendWithSevenParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -725,7 +725,7 @@ class HubConnectionTest { } @Test - public void sendWithEightParamsTriggersOnHandler() throws Exception { + public void sendWithEightParamsTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); AtomicReference value3 = new AtomicReference<>(); @@ -778,7 +778,7 @@ class HubConnectionTest { } @Test - public void sendWithCustomObjectTriggersOnHandler() throws Exception { + public void sendWithCustomObjectTriggersOnHandler() { AtomicReference value1 = new AtomicReference<>(); MockTransport mockTransport = new MockTransport(); @@ -803,7 +803,7 @@ class HubConnectionTest { } @Test - public void receiveHandshakeResponseAndMessage() throws Exception { + public void receiveHandshakeResponseAndMessage() { AtomicReference value = new AtomicReference(0.0); MockTransport mockTransport = new MockTransport(false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); @@ -825,7 +825,7 @@ class HubConnectionTest { } @Test - public void onClosedCallbackRunsWhenStopIsCalled() throws Exception { + public void onClosedCallbackRunsWhenStopIsCalled() { AtomicReference value1 = new AtomicReference<>(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); hubConnection.start(); @@ -840,7 +840,7 @@ class HubConnectionTest { } @Test - public void multipleOnClosedCallbacksRunWhenStopIsCalled() throws Exception { + public void multipleOnClosedCallbacksRunWhenStopIsCalled() { AtomicReference value1 = new AtomicReference<>(); AtomicReference value2 = new AtomicReference<>(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); @@ -866,7 +866,7 @@ class HubConnectionTest { } @Test - public void hubConnectionClosesAndRunsOnClosedCallbackAfterCloseMessageWithError() throws Exception { + public void hubConnectionClosesAndRunsOnClosedCallbackAfterCloseMessageWithError() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.onClosed((ex) -> { @@ -882,7 +882,7 @@ class HubConnectionTest { } @Test - public void callingStartOnStartedHubConnectionNoOps() throws Exception { + public void callingStartOnStartedHubConnectionNoOps() { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); hubConnection.start(); assertEquals(HubConnectionState.CONNECTED, hubConnection.getConnectionState()); @@ -895,16 +895,16 @@ class HubConnectionTest { } @Test - public void cannotSendBeforeStart() throws Exception { + public void cannotSendBeforeStart() { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); assertEquals(HubConnectionState.DISCONNECTED, hubConnection.getConnectionState()); - Throwable exception = assertThrows(HubException.class, () -> hubConnection.send("inc")); + Throwable exception = assertThrows(RuntimeException.class, () -> hubConnection.send("inc")); assertEquals("The 'send' method cannot be called if the connection is not active", exception.getMessage()); } @Test - public void errorWhenReceivingInvokeWithIncorrectArgumentLength() throws Exception { + public void errorWhenReceivingInvokeWithIncorrectArgumentLength() { MockTransport mockTransport = new MockTransport(); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.on("Send", (s) -> { @@ -938,7 +938,7 @@ class HubConnectionTest { } @Test - public void negotiateThatRedirectsForeverFailsAfter100Tries() throws InterruptedException, TimeoutException, Exception { + public void negotiateThatRedirectsForeverFailsAfter100Tries() { TestHttpClient client = new TestHttpClient().on("POST", "http://example.com/negotiate", (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"url\":\"http://example.com\"}"))); @@ -953,7 +953,7 @@ class HubConnectionTest { } @Test - public void afterSuccessfulNegotiateConnectsWithTransport() throws InterruptedException, TimeoutException, Exception { + public void afterSuccessfulNegotiateConnectsWithTransport() { TestHttpClient client = new TestHttpClient().on("POST", "http://example.com/negotiate", (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\",\"" @@ -991,7 +991,7 @@ class HubConnectionTest { } @Test - public void negotiateRedirectIsFollowed() throws Exception { + public void negotiateRedirectIsFollowed() { TestHttpClient client = new TestHttpClient().on("POST", "http://example.com/negotiate", (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"url\":\"http://testexample.com/\"}"))) .on("POST", "http://testexample.com/negotiate", @@ -1011,8 +1011,7 @@ class HubConnectionTest { } @Test - public void accessTokenProviderIsUsedForNegotiate() - throws InterruptedException, ExecutionException, TimeoutException, Exception { + public void accessTokenProviderIsUsedForNegotiate() { AtomicReference token = new AtomicReference<>(); TestHttpClient client = new TestHttpClient() .on("POST", "http://example.com/negotiate", @@ -1038,7 +1037,7 @@ class HubConnectionTest { } @Test - public void accessTokenProviderIsOverriddenFromRedirectNegotiate() throws Exception { + public void accessTokenProviderIsOverriddenFromRedirectNegotiate() { AtomicReference token = new AtomicReference<>(); TestHttpClient client = new TestHttpClient() .on("POST", "http://example.com/negotiate", (req) -> CompletableFuture.completedFuture(new HttpResponse(200, "", "{\"url\":\"http://testexample.com/\",\"accessToken\":\"newToken\"}"))) @@ -1065,7 +1064,7 @@ class HubConnectionTest { } @Test - public void connectionTimesOutIfServerDoesNotSendMessage() throws Exception { + public void connectionTimesOutIfServerDoesNotSendMessage() throws InterruptedException, ExecutionException, TimeoutException { HubConnection hubConnection = TestUtils.createHubConnection("http://example.com"); hubConnection.setServerTimeout(Duration.ofMillis(1)); hubConnection.setTickRate(Duration.ofMillis(1)); @@ -1080,7 +1079,7 @@ class HubConnectionTest { } @Test - public void connectionSendsPingsRegularly() throws InterruptedException, ExecutionException, TimeoutException, Exception { + public void connectionSendsPingsRegularly() throws InterruptedException { MockTransport mockTransport = new MockTransport(true, false); HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport); hubConnection.setKeepAliveInterval(Duration.ofMillis(1)); @@ -1099,7 +1098,7 @@ class HubConnectionTest { } @Test - public void hubConnectionCanBeStartedAfterBeingStopped() throws Exception { + public void hubConnectionCanBeStartedAfterBeingStopped() { MockTransport transport = new MockTransport(); HubConnection hubConnection = HubConnectionBuilder .create("http://example.com") @@ -1118,7 +1117,7 @@ class HubConnectionTest { } @Test - public void hubConnectionCanBeStartedAfterBeingStoppedAndRedirected() throws Exception { + public void hubConnectionCanBeStartedAfterBeingStoppedAndRedirected() { MockTransport mockTransport = new MockTransport(); TestHttpClient client = new TestHttpClient() .on("POST", "http://example.com/negotiate", (req) -> CompletableFuture diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/JsonHubProtocolTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/JsonHubProtocolTest.java index 9099aad1fa..9c9bbdce25 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/JsonHubProtocolTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/JsonHubProtocolTest.java @@ -39,7 +39,7 @@ class JsonHubProtocolTest { } @Test - public void parsePingMessage() throws Exception { + public void parsePingMessage() { String stringifiedMessage = "{\"type\":6}\u001E"; TestBinder binder = new TestBinder(PingMessage.getInstance()); @@ -51,7 +51,7 @@ class JsonHubProtocolTest { } @Test - public void parseCloseMessage() throws Exception { + public void parseCloseMessage() { String stringifiedMessage = "{\"type\":7}\u001E"; TestBinder binder = new TestBinder(new CloseMessage()); @@ -69,7 +69,7 @@ class JsonHubProtocolTest { } @Test - public void parseCloseMessageWithError() throws Exception { + public void parseCloseMessageWithError() { String stringifiedMessage = "{\"type\":7,\"error\": \"There was an error\"}\u001E"; TestBinder binder = new TestBinder(new CloseMessage("There was an error")); @@ -87,7 +87,7 @@ class JsonHubProtocolTest { } @Test - public void parseSingleMessage() throws Exception { + public void parseSingleMessage() { String stringifiedMessage = "{\"type\":1,\"target\":\"test\",\"arguments\":[42]}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage("1", "test", new Object[] { 42 })); @@ -109,7 +109,7 @@ class JsonHubProtocolTest { } @Test - public void parseSingleUnsupportedStreamItemMessage() throws Exception { + public void parseSingleUnsupportedStreamItemMessage() { String stringifiedMessage = "{\"type\":2,\"Id\":1,\"Item\":42}\u001E"; TestBinder binder = new TestBinder(null); @@ -118,7 +118,7 @@ class JsonHubProtocolTest { } @Test - public void parseSingleUnsupportedStreamInvocationMessage() throws Exception { + public void parseSingleUnsupportedStreamInvocationMessage() { String stringifiedMessage = "{\"type\":4,\"Id\":1,\"target\":\"test\",\"arguments\":[42]}\u001E"; TestBinder binder = new TestBinder(new StreamInvocationMessage("1", "test", new Object[] { 42 })); @@ -127,7 +127,7 @@ class JsonHubProtocolTest { } @Test - public void parseSingleUnsupportedCancelInvocationMessage() throws Exception { + public void parseSingleUnsupportedCancelInvocationMessage() { String stringifiedMessage = "{\"type\":5,\"invocationId\":123}\u001E"; TestBinder binder = new TestBinder(null); @@ -136,7 +136,7 @@ class JsonHubProtocolTest { } @Test - public void parseTwoMessages() throws Exception { + public void parseTwoMessages() { String twoMessages = "{\"type\":1,\"target\":\"one\",\"arguments\":[42]}\u001E{\"type\":1,\"target\":\"two\",\"arguments\":[43]}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage("1", "one", new Object[] { 42 })); @@ -167,7 +167,7 @@ class JsonHubProtocolTest { } @Test - public void parseSingleMessageMutipleArgs() throws Exception { + public void parseSingleMessageMutipleArgs() { String stringifiedMessage = "{\"type\":1,\"target\":\"test\",\"arguments\":[42, 24]}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage("1", "test", new Object[] { 42, 24 })); @@ -186,7 +186,7 @@ class JsonHubProtocolTest { } @Test - public void parseMessageWithOutOfOrderProperties() throws Exception { + public void parseMessageWithOutOfOrderProperties() { String stringifiedMessage = "{\"arguments\":[42, 24],\"type\":1,\"target\":\"test\"}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage("1", "test", new Object[] { 42, 24 })); @@ -205,7 +205,7 @@ class JsonHubProtocolTest { } @Test - public void parseCompletionMessageWithOutOfOrderProperties() throws Exception { + public void parseCompletionMessageWithOutOfOrderProperties() { String stringifiedMessage = "{\"type\":3,\"result\":42,\"invocationId\":\"1\"}\u001E"; TestBinder binder = new TestBinder(new CompletionMessage("1", 42, null)); @@ -220,7 +220,7 @@ class JsonHubProtocolTest { } @Test - public void errorWhileParsingTooManyArgumentsWithOutOfOrderProperties() throws Exception { + public void errorWhileParsingTooManyArgumentsWithOutOfOrderProperties() { String stringifiedMessage = "{\"arguments\":[42, 24],\"type\":1,\"target\":\"test\"}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage(null, "test", new Object[] { 42 })); @@ -229,7 +229,7 @@ class JsonHubProtocolTest { } @Test - public void errorWhileParsingTooManyArguments() throws Exception { + public void errorWhileParsingTooManyArguments() { String stringifiedMessage = "{\"type\":1,\"target\":\"test\",\"arguments\":[42, 24]}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage(null, "test", new Object[] { 42 })); @@ -238,7 +238,7 @@ class JsonHubProtocolTest { } @Test - public void errorWhileParsingTooFewArguments() throws Exception { + public void errorWhileParsingTooFewArguments() { String stringifiedMessage = "{\"type\":1,\"target\":\"test\",\"arguments\":[42]}\u001E"; TestBinder binder = new TestBinder(new InvocationMessage(null, "test", new Object[] { 42, 24 })); @@ -247,7 +247,7 @@ class JsonHubProtocolTest { } @Test - public void errorWhileParsingIncompleteMessage() throws Exception { + public void errorWhileParsingIncompleteMessage() { String stringifiedMessage = "{\"type\":1,\"target\":\"test\",\"arguments\":"; TestBinder binder = new TestBinder(new InvocationMessage(null, "test", new Object[] { 42, 24 })); diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java index b77e0f7999..9033861b51 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/MockTransport.java @@ -31,7 +31,7 @@ class MockTransport implements Transport { } @Override - public CompletableFuture start(String url) { + public CompletableFuture start(String url) { this.url = url; if (autoHandshake) { try { @@ -44,7 +44,7 @@ class MockTransport implements Transport { } @Override - public CompletableFuture send(String message) { + public CompletableFuture send(String message) { if (!(ignorePings && message.equals("{\"type\":6}" + RECORD_SEPARATOR))) { sentMessages.add(message); } @@ -57,7 +57,7 @@ class MockTransport implements Transport { } @Override - public void onReceive(String message) throws Exception { + public void onReceive(String message) { this.onReceiveCallBack.invoke(message); } @@ -67,7 +67,7 @@ class MockTransport implements Transport { } @Override - public CompletableFuture stop() { + public CompletableFuture stop() { onClose.accept(null); return CompletableFuture.completedFuture(null); } @@ -76,7 +76,7 @@ class MockTransport implements Transport { onClose.accept(errorMessage); } - public void receiveMessage(String message) throws Exception { + public void receiveMessage(String message) { this.onReceive(message); } diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/NegotiateResponseTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/NegotiateResponseTest.java index b8cfd15bf2..1bef3278e5 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/NegotiateResponseTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/NegotiateResponseTest.java @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test; class NegotiateResponseTest { @Test - public void VerifyNegotiateResponse() throws IOException { + public void VerifyNegotiateResponse() { String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\",\"" + "availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}," + "{\"transport\":\"ServerSentEvents\",\"transferFormats\":[\"Text\"]}," + @@ -27,7 +27,7 @@ class NegotiateResponseTest { } @Test - public void VerifyRedirectNegotiateResponse() throws IOException { + public void VerifyRedirectNegotiateResponse() { String stringNegotiateResponse = "{\"url\":\"www.example.com\"," + "\"accessToken\":\"some_access_token\"," + "\"availableTransports\":[]}"; @@ -40,7 +40,7 @@ class NegotiateResponseTest { } @Test - public void NegotiateResponseIgnoresExtraProperties() throws IOException { + public void NegotiateResponseIgnoresExtraProperties() { String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\"," + "\"extra\":\"something\"}"; NegotiateResponse negotiateResponse = new NegotiateResponse(stringNegotiateResponse); @@ -48,7 +48,7 @@ class NegotiateResponseTest { } @Test - public void NegotiateResponseIgnoresExtraComplexProperties() throws IOException { + public void NegotiateResponseIgnoresExtraComplexProperties() { String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\"," + "\"extra\":[\"something\"]}"; NegotiateResponse negotiateResponse = new NegotiateResponse(stringNegotiateResponse); diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/WebSocketTransportTest.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/WebSocketTransportTest.java index 69414d567a..e58fa22fff 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/WebSocketTransportTest.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/WebSocketTransportTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; class WebSocketTransportTest { @Test - public void WebsocketThrowsIfItCantConnect() throws Exception { + public void WebsocketThrowsIfItCantConnect() { Transport transport = new WebSocketTransport(new HashMap<>(), new DefaultHttpClient(new NullLogger()), new NullLogger()); ExecutionException exception = assertThrows(ExecutionException.class, () -> transport.start("http://www.example.com").get(1, TimeUnit.SECONDS)); assertEquals("There was an error starting the Websockets transport.", exception.getCause().getMessage()); diff --git a/clients/java/signalr/src/test/java/com/microsoft/signalr/sample/Chat.java b/clients/java/signalr/src/test/java/com/microsoft/signalr/sample/Chat.java index f559a62b69..f3fb4a1213 100644 --- a/clients/java/signalr/src/test/java/com/microsoft/signalr/sample/Chat.java +++ b/clients/java/signalr/src/test/java/com/microsoft/signalr/sample/Chat.java @@ -10,7 +10,7 @@ import com.microsoft.signalr.HubConnectionBuilder; import com.microsoft.signalr.LogLevel; public class Chat { - public static void main(String[] args) throws Exception { + public static void main(String[] args) { System.out.println("Enter the URL of the SignalR Chat you want to join"); Scanner reader = new Scanner(System.in); // Reading from System.in String input = reader.nextLine();