Repository navigation
fix: Close the response body when a stream stops #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+311
−2
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
src/test/java/com/launchdarkly/eventsource/EventSourceStreamReleaseTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| package com.launchdarkly.eventsource; | ||
|
|
||
| import org.junit.Rule; | ||
| import org.junit.Test; | ||
|
|
||
| import java.io.Closeable; | ||
| import java.io.FilterInputStream; | ||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.io.PipedInputStream; | ||
| import java.io.PipedOutputStream; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| import static com.launchdarkly.eventsource.MockConnectStrategy.ORIGIN; | ||
| import static com.launchdarkly.eventsource.TestUtils.interruptOnAnotherThread; | ||
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.hamcrest.Matchers.contains; | ||
| import static org.hamcrest.Matchers.equalTo; | ||
| import static org.hamcrest.Matchers.sameInstance; | ||
|
|
||
| /** | ||
| * Tests that EventSource closes the input stream of a connection when it stops using | ||
| * the connection. It must close the stream on the reading thread, after the connection | ||
| * closer has run. | ||
| */ | ||
| @SuppressWarnings("javadoc") | ||
| public class EventSourceStreamReleaseTest { | ||
| private static final String CLOSER_RAN = "closer ran"; | ||
| private static final String STREAM_CLOSED = "input stream closed"; | ||
| private static final String MESSAGE = "data: first\n\n"; | ||
|
|
||
| @Rule public TestScopedLoggerRule testLogger = new TestScopedLoggerRule(); | ||
|
|
||
| // Provides one stream that stays open until the connection closer runs, and records | ||
| // when the closer runs and when the input stream is closed. | ||
| private static class TrackedStreamHandler extends MockConnectStrategy.RequestHandler { | ||
| final List<String> actions = Collections.synchronizedList(new ArrayList<>()); | ||
| volatile Thread streamClosingThread; | ||
| private final PipedOutputStream writeStream = new PipedOutputStream(); | ||
| private final InputStream readStream; | ||
|
|
||
| TrackedStreamHandler(String data) throws IOException { | ||
| this(data, false); | ||
| } | ||
|
|
||
| TrackedStreamHandler(String data, boolean failOnClose) throws IOException { | ||
| readStream = new FilterInputStream(new PipedInputStream(writeStream)) { | ||
| @Override | ||
| public void close() throws IOException { | ||
| actions.add(STREAM_CLOSED); | ||
| streamClosingThread = Thread.currentThread(); | ||
| super.close(); | ||
| if (failOnClose) { | ||
| throw new IOException("deliberate error"); | ||
| } | ||
| } | ||
| }; | ||
| writeStream.write(data.getBytes()); | ||
| } | ||
|
|
||
| @Override | ||
| protected InputStream getStream() { | ||
| return readStream; | ||
| } | ||
|
|
||
| @Override | ||
| protected Closeable makeCloser() { | ||
| return () -> { | ||
| actions.add(CLOSER_RAN); | ||
| writeStream.close(); | ||
| }; | ||
| } | ||
|
|
||
| void endStream() throws IOException { | ||
| writeStream.close(); | ||
| } | ||
| } | ||
|
|
||
| private EventSource startEventSource(TrackedStreamHandler handler) throws Exception { | ||
| MockConnectStrategy mock = new MockConnectStrategy(); | ||
| mock.configureRequests(handler, MockConnectStrategy.respondWithDataAndThenStayOpen(MESSAGE)); | ||
| EventSource es = new EventSource.Builder(mock) | ||
| .errorStrategy(ErrorStrategy.alwaysContinue()) | ||
| .retryDelayStrategy(RetryDelayStrategy.defaultStrategy().initialDelay(1, TimeUnit.MILLISECONDS)) | ||
| .logger(testLogger.getLogger()) | ||
| .build(); | ||
| es.start(); | ||
| assertThat(es.readAnyEvent(), equalTo(new MessageEvent("message", "first", null, ORIGIN))); | ||
| return es; | ||
| } | ||
|
|
||
| @Test | ||
| public void interruptOnReadingThreadClosesInputStreamAfterCloser() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE); | ||
| try (EventSource es = startEventSource(handler)) { | ||
| es.interrupt(); | ||
|
|
||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| assertThat(handler.streamClosingThread, sameInstance(Thread.currentThread())); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void stopOnReadingThreadClosesInputStreamAfterCloser() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE); | ||
| try (EventSource es = startEventSource(handler)) { | ||
| es.stop(); | ||
|
|
||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void closeOnReadingThreadClosesInputStreamAfterCloser() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE); | ||
| EventSource es = startEventSource(handler); | ||
| es.close(); | ||
|
|
||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| } | ||
|
|
||
| @Test | ||
| public void interruptFromAnotherThreadClosesInputStreamOnReadingThread() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE); | ||
| try (EventSource es = startEventSource(handler)) { | ||
| interruptOnAnotherThread(es); | ||
|
|
||
| assertThat(es.readAnyEvent(), equalTo(new FaultEvent(new StreamClosedByCallerException()))); | ||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| assertThat(handler.streamClosingThread, sameInstance(Thread.currentThread())); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void errorWhenClosingInputStreamDoesNotStopReconnect() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE, true); | ||
| try (EventSource es = startEventSource(handler)) { | ||
| es.interrupt(); | ||
|
|
||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| assertThat(es.readAnyEvent(), equalTo(new StartedEvent())); | ||
| assertThat(es.readAnyEvent(), equalTo(new MessageEvent("message", "first", null, ORIGIN))); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void endOfStreamClosesInputStream() throws Exception { | ||
| TrackedStreamHandler handler = new TrackedStreamHandler(MESSAGE); | ||
| try (EventSource es = startEventSource(handler)) { | ||
| handler.endStream(); | ||
|
|
||
| assertThat(es.readAnyEvent(), equalTo(new FaultEvent(new StreamClosedByServerException()))); | ||
| assertThat(handler.actions, contains(CLOSER_RAN, STREAM_CLOSED)); | ||
| } | ||
| } | ||
| } |
123 changes: 123 additions & 0 deletions
123
src/test/java/com/launchdarkly/eventsource/HttpConnectStrategyHttp2Test.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| package com.launchdarkly.eventsource; | ||
|
|
||
| import org.junit.Rule; | ||
| import org.junit.Test; | ||
|
|
||
| import java.util.Collections; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.hamcrest.Matchers.equalTo; | ||
|
|
||
| import okhttp3.ConnectionPool; | ||
| import okhttp3.Protocol; | ||
| import okhttp3.mockwebserver.MockResponse; | ||
| import okhttp3.mockwebserver.MockWebServer; | ||
| import okhttp3.mockwebserver.RecordedRequest; | ||
|
|
||
| /** | ||
| * Tests of EventSource with real HTTP/2 connections. An HTTP/2 connection stays in use | ||
| * until its response is released, so these tests check that EventSource releases the | ||
| * response when it stops using a stream. | ||
| */ | ||
| @SuppressWarnings("javadoc") | ||
| public class HttpConnectStrategyHttp2Test { | ||
| @Rule public TestScopedLoggerRule testLogger = new TestScopedLoggerRule(); | ||
|
|
||
| // The body is larger than the client reads before the test stops the stream, so the | ||
| // stream is still active at that time. | ||
| private static MockResponse streamResponse(String eventData) { | ||
| StringBuilder body = new StringBuilder("data: " + eventData + "\n\n"); | ||
| for (int i = 0; i < 50000; i++) { | ||
| body.append(":\n"); | ||
| } | ||
| return new MockResponse() | ||
| .setHeader("Content-Type", "text/event-stream") | ||
| .setBody(body.toString()); | ||
| } | ||
|
|
||
| private static MockWebServer startHttp2Server(MockResponse... responses) throws Exception { | ||
| MockWebServer server = new MockWebServer(); | ||
| server.setProtocols(Collections.singletonList(Protocol.H2_PRIOR_KNOWLEDGE)); | ||
| for (MockResponse response: responses) { | ||
| server.enqueue(response); | ||
| } | ||
| server.start(); | ||
| return server; | ||
| } | ||
|
|
||
| private EventSource makeEventSource(MockWebServer server, ConnectionPool pool) { | ||
| return new EventSource.Builder( | ||
| ConnectStrategy.http(server.url("/").uri()) | ||
| .clientBuilderActions(b -> b | ||
| .protocols(Collections.singletonList(Protocol.H2_PRIOR_KNOWLEDGE)) | ||
| .connectionPool(pool))) | ||
| .errorStrategy(ErrorStrategy.alwaysContinue()) | ||
| .retryDelayStrategy(RetryDelayStrategy.defaultStrategy().initialDelay(1, TimeUnit.MILLISECONDS)) | ||
| .logger(testLogger.getLogger()) | ||
| .build(); | ||
| } | ||
|
|
||
| private static void readUntilMessage(EventSource es, String expectedData) throws Exception { | ||
| for (int i = 0; i < 10; i++) { | ||
| StreamEvent event = es.readAnyEvent(); | ||
| if (event instanceof MessageEvent) { | ||
| assertThat(((MessageEvent)event).getData(), equalTo(expectedData)); | ||
| return; | ||
| } | ||
| } | ||
| throw new AssertionError("did not receive message \"" + expectedData + "\""); | ||
| } | ||
|
|
||
| @Test | ||
| public void interruptOnReadingThreadReleasesConnection() throws Exception { | ||
| try (MockWebServer server = startHttp2Server(streamResponse("first"))) { | ||
| ConnectionPool pool = new ConnectionPool(5, 1, TimeUnit.MINUTES); | ||
| try (EventSource es = makeEventSource(server, pool)) { | ||
| readUntilMessage(es, "first"); | ||
| assertThat(pool.idleConnectionCount(), equalTo(0)); | ||
|
|
||
| es.interrupt(); | ||
|
|
||
| assertThat(pool.connectionCount(), equalTo(1)); | ||
| assertThat(pool.idleConnectionCount(), equalTo(1)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void reconnectAfterInterruptOnReadingThreadUsesNewConnectionIfPoolKeepsNoIdleConnections() | ||
| throws Exception { | ||
| try (MockWebServer server = startHttp2Server(streamResponse("first"), streamResponse("second"))) { | ||
| ConnectionPool pool = new ConnectionPool(0, 1, TimeUnit.SECONDS); | ||
| try (EventSource es = makeEventSource(server, pool)) { | ||
| readUntilMessage(es, "first"); | ||
|
|
||
| es.interrupt(); | ||
| assertThat(pool.connectionCount(), equalTo(0)); | ||
|
|
||
| readUntilMessage(es, "second"); | ||
|
|
||
| RecordedRequest request1 = server.takeRequest(1, TimeUnit.SECONDS); | ||
| RecordedRequest request2 = server.takeRequest(1, TimeUnit.SECONDS); | ||
| assertThat(request1.getSequenceNumber(), equalTo(0)); | ||
| // A sequence number of 0 means that the request was the first one on its connection. | ||
| assertThat(request2.getSequenceNumber(), equalTo(0)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void stopOnReadingThreadReleasesConnection() throws Exception { | ||
| try (MockWebServer server = startHttp2Server(streamResponse("first"))) { | ||
| ConnectionPool pool = new ConnectionPool(5, 1, TimeUnit.MINUTES); | ||
| try (EventSource es = makeEventSource(server, pool)) { | ||
| readUntilMessage(es, "first"); | ||
|
|
||
| es.stop(); | ||
|
|
||
| assertThat(pool.idleConnectionCount(), equalTo(1)); | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is outside the synchronized block because it closing the connection is a potentially blocking call.