Skip to content
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

Improve request cancelation handling. #103

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 1 addition & 25 deletions src/main/java/com/android/volley/ExecutorDelivery.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,31 +88,7 @@ public ResponseDeliveryRunnable(Request request, Response response, Runnable run
@SuppressWarnings("unchecked")
@Override
public void run() {
// If this request has canceled, finish it and don't deliver.
if (mRequest.isCanceled()) {
mRequest.finish("canceled-at-delivery");
return;
}

// Deliver a normal response or error, depending.
if (mResponse.isSuccess()) {
mRequest.deliverResponse(mResponse.result);
} else {
mRequest.deliverError(mResponse.error);
}

// If this is an intermediate response, add a marker, otherwise we're done
// and the request can be finished.
if (mResponse.intermediate) {
mRequest.addMarker("intermediate-response");
} else {
mRequest.finish("done");
}

// If we have been provided a post-delivery runnable, run it.
if (mRunnable != null) {
mRunnable.run();
}
mRequest.deliverResponse(mResponse, mRunnable);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you call that something different, maybe handleResponse or prepareResponseDelivery of maybeDeliverResponse or something? It was a bit confusing to read for me since there are now 2 deliverResponse with different arguments and having different jobs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agreed this is confusing; I couldn't come up with a better name. If we end up keeping this (per my other comment) then maybe deliverResponseAndFinish() or something to indicate the additional work that this method is responsible for doing.

}
}
}
100 changes: 94 additions & 6 deletions src/main/java/com/android/volley/Request.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public interface Method {
int PATCH = 7;
}

/** Callback interface to notify listeners when a request has been canceled. */
public interface CancelListener {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names seems odd. Ideas for a better name:

  • OnRequestCanceledListener
  • CancellationListener
  • OnCancelListener

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CancellationListener makes sense - the intent is to be consistent with "ErrorListener", so the prefixes should both be nouns describing what happened.

/** Called when a request was canceled before the response could be delivered. */
void onRequestCanceled();
}

/**
* Callback to notify when the network request returns.
*/
Expand Down Expand Up @@ -83,8 +89,11 @@ public interface Method {
/** Default tag for {@link TrafficStats}. */
private final int mDefaultTrafficStatsTag;

/** Lock to guard state which can be mutated after a request is added to the queue. */
private final Object mLock = new Object();

/** Listener interface for errors. */
private final Response.ErrorListener mErrorListener;
private Response.ErrorListener mErrorListener;

/** Sequence number of this request, used to enforce FIFO ordering. */
private Integer mSequence;
Expand All @@ -96,9 +105,11 @@ public interface Method {
private boolean mShouldCache = true;

/** Whether or not this request has been canceled. */
// GuardedBy(mLock)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're OK with importing android.support.annotation.GuardedBy you could use @GuardedBy("mLock").

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a dependency on the support lib yet, so for now I'm holding off on this. At some point later on we might add it to cover things like this, @VisibleForTesting, etc.

private boolean mCanceled = false;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of guarding this by a lock, would it be sufficient to make this volatile? Since we don't have a mechanism to un-cancel a request, we know that once isCancelled returns true, it will always return true, so we mainly have to make sure that once one thread cancels, all other threads see that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not sufficient as it stands due to holding mLock around the response dispatching below; it might be sufficient if we change that, but I'm not sure that n volatile variables is necessarily more performant than a simple lock, and it's certainly more tricky to reason about :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this a little closer, I see that synchronization is being added to methods that are likely to be called on the main thread. This should be avoided. In particular, Request.cancel() is waiting for a lock held by the ExecutorDelivery.

/** Whether or not a response has been delivered for this request yet. */
// GuardedBy(mLock)
private boolean mResponseDelivered = false;

/** Whether the request should be retried in the event of an HTTP 5xx (server) error. */
Expand All @@ -118,10 +129,11 @@ public interface Method {
private Object mTag;

/** Listener that will be notified when a response has been delivered. */
// GuardedBy(mLock)
private NetworkRequestCompleteListener mRequestCompleteListener;

/** Object to guard access to mRequestCompleteListener. */
private final Object mLock = new Object();
/** Optional listener for request cancel events. */
private CancelListener mCancelListener;

/**
* Creates a new request with the given URL and error listener. Note that
Expand Down Expand Up @@ -184,6 +196,18 @@ public Response.ErrorListener getErrorListener() {
return mErrorListener;
}

/**
* @return this request's {@link CancelListener}, if any.
*/
public CancelListener getCancelListener() {
return mCancelListener;
}

/** Sets a {@link CancelListener} which will be notified when the request is canceled. */
public void setCancelListener(CancelListener listener) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not add another ctor to accommodate this?

mCancelListener = listener;
}

/**
* @return A tag for use with {@link TrafficStats#setThreadStatsTag(int)}
*/
Expand Down Expand Up @@ -320,17 +344,81 @@ public Cache.Entry getCacheEntry() {
}

/**
* Mark this request as canceled. No callback will be delivered.
* Mark this request as canceled.
*
* <p>No callback will be delivered as long as {@link ExecutorDelivery} is used as the
* {@link ResponseDelivery} and as long as this method is not overridden. There are no
* guarantees otherwise.
*
* <p>Subclasses should generally override {@link #onCanceled()} instead to perform an action
* when the request is canceled (such as clearing response listeners to avoid leaks).
*/
public void cancel() {
mCanceled = true;
synchronized (mLock) {
mCanceled = true;
onCanceledInternal();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't know what happens in onCancelled, should we maybe check here if the request is already cancelled and avoid double-cancellation? Especially if you make mCanceled volatile as suggested above rather than guarded, but I think it's safer either way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point; I could change this to avoid double-cancellation.

}
}

/**
* Returns true if this request has been canceled.
*/
public boolean isCanceled() {
return mCanceled;
synchronized (mLock) {
return mCanceled;
}
}

private void onCanceledInternal() {
mErrorListener = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mErrorListener needs to be volatile (or access to it needs to be synchronized) to ensure getErrorListener() returns the correct value. Same for mCancelListener.

if (mCancelListener != null) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need to synchronize access to mCancelListener because setCancelListener() could be called on a different thread. Or pass it in through the ctor and make it volatile.

mCancelListener.onRequestCanceled();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other callbacks run on the same thread as the ResponseDelivery being used whereas this runs on the calling thread. I realize in most cases those threads will be the same (main thread) but what do you think about providing a stronger guarantee about the thread onRequestCanceled() runs on?

mCancelListener = null;
}
onCanceled();
}

/**
* Called when a request is canceled.
*
* <p>The default implementation does nothing. Subclasses may override this to perform custom
* actions, such as clearing response listeners to avoid leaks.
*/
protected void onCanceled() {
}

/** Deliver the given response as long as the request isn't canceled. */
void deliverResponse(Response<T> response, Runnable runnable) {
// To uphold the contract of cancel(), we hold the same lock here around both the check of
// isCanceled() as well as the actual response delivery. This ensures that after a call
// to cancel() returns, we will never deliver the response.
synchronized (mLock) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from open calls (without lock held) to closed calls makes me nervous about deadlock.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a lot of stuff behind this lock, I'm not sure that's needed. I think instead we could add a check whether finish() has been called (addMarker() after finish() leads to IllegalStateException). Since this method (and finish) are package-private it is anyway not possible for users to access them without using ExecutorDelivery. And as long as ExecutorDelivery always runs them on the same thread, we can't have conflicts here as far as I can tell. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joebowbeer I acknowledge the concern, but I don't see a way to properly implement the guarantees that are advertised by cancel() without ensuring that the same lock is held for both cancel() and for the response dispatching itself. Without this lock, it's possible for someone to call cancel() and still have the callbacks called (if we've already checked isCanceled() here).

@uhager It doesn't seem to me like finish() is always called (in the response.intermediate case) so I'm not sure that works in every case, right? And the assumption about ExecutorDelivery running on the same thread is the one this locking is intended to resolve.

Another option might be to weaken the guarantee by stating that you must call cancel() from the same thread as the ResponseDelivery being used (typically the main thread) in order to guarantee that the callbacks aren't invoked. If you call cancel() from another thread, and the main thread is in the process of delivering responses, it may still end up delivering the response. In this case we could leave the dispatching as it was and just accept the risk of a post-cancel dispatch. However, this would mean needing to lock writes to the error listener / response listener.

@jjoslin - any thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT the same lock needs to be held during cancel() and response dispatching to uphold the cancel() contract as @jpd236 states. I feel like this is a reasonable thing to do but also acknowledge that it does create the potential for deadlock (although I think it will be rare in practice). I support/prefer the current implementation but I wouldn't be opposed to weakening the guarantee either.

// If this request has canceled, finish it and don't deliver.
if (isCanceled()) {
finish("canceled-at-delivery");
return;
}

// Deliver a normal response or error, depending.
if (response.isSuccess()) {
deliverResponse(response.result);
} else {
deliverError(response.error);
}

// If this is an intermediate response, add a marker, otherwise we're done
// and the request can be finished.
if (response.intermediate) {
addMarker("intermediate-response");
} else {
finish("done");
}

// If we have been provided a post-delivery runnable, run it.
if (runnable != null) {
runnable.run();
}
}
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/android/volley/toolbox/ImageRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class ImageRequest extends Request<Bitmap> {
/** Default backoff multiplier for image requests */
public static final float DEFAULT_IMAGE_BACKOFF_MULT = 2f;

private final Response.Listener<Bitmap> mListener;
private Response.Listener<Bitmap> mListener;
private final Config mDecodeConfig;
private final int mMaxWidth;
private final int mMaxHeight;
Expand Down Expand Up @@ -221,6 +221,11 @@ protected void deliverResponse(Bitmap response) {
}
}

@Override
protected void onCanceled() {
mListener = null;
}

/**
* Returns the largest power-of-two divisor for use in downscaling a bitmap
* that will not result in the scaling past the desired dimensions.
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/android/volley/toolbox/JsonRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public abstract class JsonRequest<T> extends Request<T> {
private static final String PROTOCOL_CONTENT_TYPE =
String.format("application/json; charset=%s", PROTOCOL_CHARSET);

private final Listener<T> mListener;
private Listener<T> mListener;
private final String mRequestBody;

/**
Expand Down Expand Up @@ -68,6 +68,11 @@ protected void deliverResponse(T response) {
}
}

@Override
protected void onCanceled() {
mListener = null;
}

@Override
abstract protected Response<T> parseNetworkResponse(NetworkResponse response);

Expand Down
29 changes: 25 additions & 4 deletions src/main/java/com/android/volley/toolbox/RequestFuture.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.android.volley.Response;
import com.android.volley.VolleyError;

import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
Expand All @@ -35,8 +36,8 @@
*
* // If you want to be able to cancel the request:
* future.setRequest(requestQueue.add(request));
* // Also be sure not to call Request.setCancelListener.
*
* // Otherwise:
* requestQueue.add(request);
*
* try {
Expand All @@ -51,9 +52,10 @@
*
* @param <T> The type of parsed response this future expects.
*/
public class RequestFuture<T> implements Future<T>, Response.Listener<T>,
Response.ErrorListener {
public class RequestFuture<T> implements Future<T>, Response.Listener<T>, Response.ErrorListener,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how much difference this makes, but the pattern I like to use for classes that implement Future is to have them wrap an internal FutureTask instance to handle the synchronization. For example, see BackgroundTask in JCiP (Ch. 9 ). This makes it easier to get the synchronization correct, and makes it more apparent that it is.

Request.CancelListener {
private Request<?> mRequest;
private boolean mRequestCanceled = false;
private boolean mResultReceived = false;
private T mResult;
private VolleyError mException;
Expand All @@ -66,6 +68,11 @@ private RequestFuture() {}

public void setRequest(Request<?> request) {
mRequest = request;
if (mRequest.getCancelListener() != null) {
throw new IllegalArgumentException("Provided request already has a cancel listener");
}
mRequest.setCancelListener(this);
// Not much we can do if a different cancel listener is set after this point.
}

@Override
Expand Down Expand Up @@ -107,6 +114,10 @@ private synchronized T doGet(Long timeoutMs)
return mResult;
}

if (isCancelled()) {
throw new CancellationException();
}

if (timeoutMs == null) {
wait(0);
} else if (timeoutMs > 0) {
Expand All @@ -121,6 +132,10 @@ private synchronized T doGet(Long timeoutMs)
throw new TimeoutException();
}

if (isCancelled()) {
throw new CancellationException();
}

return mResult;
}

Expand All @@ -129,7 +144,7 @@ public boolean isCancelled() {
if (mRequest == null) {
return false;
}
return mRequest.isCanceled();
return mRequestCanceled;
}

@Override
Expand All @@ -149,5 +164,11 @@ public synchronized void onErrorResponse(VolleyError error) {
mException = error;
notifyAll();
}

@Override
public synchronized void onRequestCanceled() {
mRequestCanceled = true;
notifyAll();
}
}

7 changes: 6 additions & 1 deletion src/main/java/com/android/volley/toolbox/StringRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* A canned request for retrieving the response body at a given URL as a String.
*/
public class StringRequest extends Request<String> {
private final Listener<String> mListener;
private Listener<String> mListener;

/**
* Creates a new request with the given method.
Expand Down Expand Up @@ -62,6 +62,11 @@ protected void deliverResponse(String response) {
}
}

@Override
protected void onCanceled() {
mListener = null;
}

@Override
protected Response<String> parseNetworkResponse(NetworkResponse response) {
String parsed;
Expand Down
20 changes: 20 additions & 0 deletions src/test/java/com/android/volley/RequestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;

import java.util.concurrent.atomic.AtomicBoolean;

import static org.junit.Assert.*;

@RunWith(RobolectricTestRunner.class)
Expand All @@ -45,6 +47,24 @@ public class RequestTest {
assertTrue(immediate.compareTo(high) < 0);
}

@Test public void cancelListener() {
TestRequest request = new TestRequest(Priority.NORMAL);
final AtomicBoolean listenerCalled = new AtomicBoolean(false);
request.setCancelListener(new Request.CancelListener() {
@Override
public void onRequestCanceled() {
listenerCalled.set(true);
}
});
assertFalse(request.isCanceled());
assertFalse(listenerCalled.get());

request.cancel();

assertTrue(request.isCanceled());
assertTrue(listenerCalled.get());
}

private class TestRequest extends Request<Object> {
private Priority mPriority = Priority.NORMAL;
public TestRequest(Priority priority) {
Expand Down
Loading