Skip to content

Commit edeb5dd

Browse files
authored
Merge pull request #54 from rogierslag/reproducer/delayed-jackson-response-lifecycle
Wait for pending body writes before completing Netty responses
2 parents c2240fd + b8c7c0e commit edeb5dd

6 files changed

Lines changed: 763 additions & 15 deletions

File tree

embedded-server/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@
115115
<artifactId>resteasy-jaxb-provider</artifactId>
116116
<scope>test</scope>
117117
</dependency>
118+
<dependency>
119+
<groupId>org.jboss.resteasy</groupId>
120+
<artifactId>resteasy-jackson2-provider</artifactId>
121+
<scope>test</scope>
122+
</dependency>
118123
<!-- This needs a new release of RESTEasy 6.2.13+ for this to be included
119124
<dependency>
120125
<groupId>org.jboss.resteasy</groupId>

embedded-server/src/main/java/org/jboss/resteasy/plugins/server/netty/ChunkOutputStream.java

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package org.jboss.resteasy.plugins.server.netty;
77

88
import java.io.IOException;
9+
import java.io.OutputStream;
910
import java.util.concurrent.CompletableFuture;
1011
import java.util.concurrent.CompletionStage;
1112

@@ -14,9 +15,11 @@
1415

1516
import io.netty.buffer.ByteBuf;
1617
import io.netty.buffer.Unpooled;
18+
import io.netty.channel.ChannelFuture;
1719
import io.netty.channel.ChannelHandlerContext;
1820
import io.netty.channel.ChannelPromise;
1921
import io.netty.handler.codec.http.DefaultHttpContent;
22+
import io.netty.util.concurrent.Future;
2023

2124
/**
2225
* Class to help application that are built to write to an
@@ -43,6 +46,12 @@ public class ChunkOutputStream extends AsyncOutputStream {
4346
private final ByteBuf buffer;
4447
private final ChannelHandlerContext ctx;
4548
private final NettyHttpResponse response;
49+
// All lifecycle state below is guarded by writeLock.
50+
private int pendingWrites = 0;
51+
private Throwable writeFailure = null;
52+
private ChannelPromise responsePromise = null;
53+
private boolean finishRequested = false;
54+
private boolean responseWriteStarted = false;
4655

4756
ChunkOutputStream(final NettyHttpResponse response, final ChannelHandlerContext ctx, final int chunksize) {
4857
this.response = response;
@@ -56,6 +65,7 @@ public class ChunkOutputStream extends AsyncOutputStream {
5665
@Override
5766
public void write(int b) throws IOException {
5867
synchronized (writeLock) {
68+
ensureOpen();
5969
if (buffer.maxWritableBytes() < 1) {
6070
flush();
6171
}
@@ -67,6 +77,8 @@ public void reset() {
6777
if (response.isCommitted())
6878
throw new IllegalStateException(Messages.MESSAGES.responseIsCommitted());
6979
synchronized (writeLock) {
80+
if (finishRequested)
81+
throw new IllegalStateException(Messages.MESSAGES.responseIsCommitted());
7082
buffer.clear();
7183
}
7284
}
@@ -88,6 +100,7 @@ private void write(byte[] b, int off, int len, ChannelPromise promise) throws IO
88100
int spaceLeftInCurrentChunk;
89101
MultiPromise mp = new MultiPromise(ctx, promise);
90102
synchronized (writeLock) {
103+
ensureOpen();
91104
while ((spaceLeftInCurrentChunk = buffer.maxWritableBytes()) < dataLengthLeftToWrite) {
92105
buffer.writeBytes(b, dataToWriteOffset, spaceLeftInCurrentChunk);
93106
dataToWriteOffset = dataToWriteOffset + spaceLeftInCurrentChunk;
@@ -109,13 +122,16 @@ public void flush() throws IOException {
109122

110123
private void flush(ChannelPromise promise) throws IOException {
111124
synchronized (writeLock) {
125+
ensureOpen();
112126
int readable = buffer.readableBytes();
113127
if (readable == 0) {
114128
promise.setSuccess();
115129
return;
116130
}
117131
if (!response.isCommitted())
118132
response.prepareChunkStream();
133+
pendingWrites++;
134+
promise.addListener(this::bodyWriteComplete);
119135
ctx.writeAndFlush(new DefaultHttpContent(buffer.copy()), promise);
120136
buffer.clear();
121137
}
@@ -157,4 +173,85 @@ public CompletionStage<Void> asyncWrite(byte[] bytes, int offset, int length) {
157173
}
158174
return ret;
159175
}
176+
177+
/**
178+
* Closes the entity-output lifecycle and completes the HTTP response after every body write has completed.
179+
* The entity stream can wrap this root stream, hence it is flushed while holding the write lock. Any tail emitted by
180+
* that flush is registered before the response is marked as finished.
181+
*/
182+
ChannelFuture finish(OutputStream entityOutputStream) throws IOException {
183+
ChannelPromise result;
184+
boolean completeResponse;
185+
synchronized (writeLock) {
186+
if (finishRequested) {
187+
return responsePromise;
188+
}
189+
if (entityOutputStream != null) {
190+
entityOutputStream.flush();
191+
}
192+
finishRequested = true;
193+
responsePromise = ctx.newPromise();
194+
result = responsePromise;
195+
completeResponse = pendingWrites == 0;
196+
}
197+
if (completeResponse) {
198+
completeResponse();
199+
}
200+
return result;
201+
}
202+
203+
private void ensureOpen() throws IOException {
204+
if (finishRequested) {
205+
throw new IOException(Messages.MESSAGES.responseIsCommitted());
206+
}
207+
}
208+
209+
private void bodyWriteComplete(Future<?> future) {
210+
boolean completeResponse;
211+
synchronized (writeLock) {
212+
pendingWrites--;
213+
if (!future.isSuccess() && writeFailure == null) {
214+
writeFailure = future.cause() == null
215+
? new IOException("Response body write failed without a cause")
216+
: future.cause();
217+
}
218+
completeResponse = finishRequested && pendingWrites == 0;
219+
}
220+
if (!future.isSuccess()) {
221+
// Once body bytes may have reached the peer, only closing the transport can prevent a clean partial response.
222+
ctx.close();
223+
}
224+
if (completeResponse) {
225+
completeResponse();
226+
}
227+
}
228+
229+
private void completeResponse() {
230+
final ChannelPromise result;
231+
final Throwable failure;
232+
synchronized (writeLock) {
233+
if (!finishRequested || pendingWrites != 0 || responseWriteStarted) {
234+
return;
235+
}
236+
responseWriteStarted = true;
237+
result = responsePromise;
238+
failure = writeFailure;
239+
}
240+
241+
if (failure != null) {
242+
ctx.close().addListener(ignored -> result.tryFailure(failure));
243+
return;
244+
}
245+
246+
response.writeResponseTermination().addListener(future -> {
247+
if (future.isSuccess()) {
248+
result.trySuccess();
249+
} else {
250+
Throwable cause = future.cause() == null
251+
? new IOException("Response termination write failed without a cause")
252+
: future.cause();
253+
ctx.close().addListener(ignored -> result.tryFailure(cause));
254+
}
255+
});
256+
}
160257
}

embedded-server/src/main/java/org/jboss/resteasy/plugins/server/netty/NettyHttpResponse.java

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,28 @@
3232
import io.netty.handler.codec.http.LastHttpContent;
3333

3434
/**
35+
* Body write submission and completion are ordered by the root {@link ChunkOutputStream}'s write lock. Terminal writes
36+
* can be requested by RESTEasy dispatch or by an asynchronous Netty promise callback. {@link #sendError(int, String)}
37+
* and {@link #writeResponseTermination()} therefore synchronize on this response, making terminal message selection,
38+
* write submission and future publication one transition.
39+
*
3540
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
3641
* @version $Revision: 1 $
3742
*/
3843
public class NettyHttpResponse implements HttpResponse {
3944
private static final int EMPTY_CONTENT_LENGTH = 0;
4045
private int status = 200;
41-
private OutputStream os;
46+
// RESTEasy writer interceptors can replace this stream with a wrapper around the root stream.
47+
private OutputStream entityOutputStream;
48+
// The root stream owns the Netty body-write promises and therefore remains stable when the entity stream is replaced.
49+
private final ChunkOutputStream rootChunkOutputStream;
4250
private final MultivaluedMap<String, Object> outputHeaders;
4351
private final ChannelHandlerContext ctx;
4452
private boolean committed;
4553
private final boolean keepAlive;
4654
private final ResteasyProviderFactory providerFactory;
4755
private final HttpMethod method;
56+
private ChannelFuture terminationFuture;
4857

4958
public NettyHttpResponse(final ChannelHandlerContext ctx, final boolean keepAlive,
5059
final ResteasyProviderFactory providerFactory) {
@@ -55,15 +64,18 @@ public NettyHttpResponse(final ChannelHandlerContext ctx, final boolean keepAliv
5564
final ResteasyProviderFactory providerFactory, final HttpMethod method) {
5665
outputHeaders = new MultivaluedMapImpl<String, Object>();
5766
this.method = method;
58-
os = (method == null || !method.equals(HttpMethod.HEAD)) ? new ChunkOutputStream(this, ctx, 1000) : null; //[RESTEASY-1627]
67+
rootChunkOutputStream = (method == null || !method.equals(HttpMethod.HEAD))
68+
? new ChunkOutputStream(this, ctx, 1000)
69+
: null; //[RESTEASY-1627]
70+
entityOutputStream = rootChunkOutputStream;
5971
this.ctx = ctx;
6072
this.keepAlive = keepAlive;
6173
this.providerFactory = providerFactory;
6274
}
6375

6476
@Override
65-
public void setOutputStream(OutputStream os) {
66-
this.os = os;
77+
public void setOutputStream(OutputStream entityOutputStream) {
78+
this.entityOutputStream = entityOutputStream;
6779
}
6880

6981
@Override
@@ -83,7 +95,7 @@ public MultivaluedMap<String, Object> getOutputHeaders() {
8395

8496
@Override
8597
public OutputStream getOutputStream() throws IOException {
86-
return os;
98+
return entityOutputStream;
8799
}
88100

89101
@Override
@@ -97,7 +109,7 @@ public void sendError(int status) throws IOException {
97109
}
98110

99111
@Override
100-
public void sendError(int status, String message) throws IOException {
112+
public synchronized void sendError(int status, String message) throws IOException {
101113
if (committed) {
102114
throw new IllegalStateException();
103115
}
@@ -116,8 +128,8 @@ public void sendError(int status, String message) throws IOException {
116128
}
117129
// Add keep alive or connection close header
118130
transformResponseHeaders(response);
119-
ctx.writeAndFlush(response);
120131
committed = true;
132+
terminationFuture = ctx.writeAndFlush(response);
121133
}
122134

123135
@Override
@@ -157,6 +169,10 @@ private void transformResponseHeaders(io.netty.handler.codec.http.HttpResponse r
157169
RestEasyHttpResponseEncoder.transformHeaders(this, res, providerFactory);
158170
}
159171

172+
/**
173+
* Called by {@link ChunkOutputStream} while it holds its write lock. Body-write completion reacquires that lock before
174+
* it can request termination, which makes this committed state visible to the completion path.
175+
*/
160176
public void prepareChunkStream() {
161177
committed = true;
162178
DefaultHttpResponse response = getDefaultHttpResponse();
@@ -165,14 +181,11 @@ public void prepareChunkStream() {
165181
}
166182

167183
public void finish() throws IOException {
168-
if (os != null)
169-
os.flush();
170184
ChannelFuture future;
171-
if (isCommitted()) {
172-
// if committed this means the output stream was used.
173-
future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
185+
if (rootChunkOutputStream != null) {
186+
future = rootChunkOutputStream.finish(entityOutputStream);
174187
} else {
175-
future = ctx.writeAndFlush(getEmptyHttpResponse());
188+
future = writeResponseTermination();
176189
}
177190

178191
if (!isKeepAlive()) {
@@ -181,10 +194,20 @@ public void finish() throws IOException {
181194

182195
}
183196

197+
synchronized ChannelFuture writeResponseTermination() {
198+
if (terminationFuture != null) {
199+
return terminationFuture;
200+
}
201+
Object terminalMessage = isCommitted() ? LastHttpContent.EMPTY_LAST_CONTENT : getEmptyHttpResponse();
202+
committed = true;
203+
terminationFuture = ctx.writeAndFlush(terminalMessage);
204+
return terminationFuture;
205+
}
206+
184207
@Override
185208
public void flushBuffer() throws IOException {
186-
if (os != null)
187-
os.flush();
209+
if (entityOutputStream != null)
210+
entityOutputStream.flush();
188211
ctx.flush();
189212
}
190213
}

0 commit comments

Comments
 (0)