-
Notifications
You must be signed in to change notification settings - Fork 754
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -56,6 +56,12 @@ public interface Method { | |
int PATCH = 7; | ||
} | ||
|
||
/** Callback interface to notify listeners when a request has been canceled. */ | ||
public interface CancelListener { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The names seems odd. Ideas for a better name:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
*/ | ||
|
@@ -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; | ||
|
@@ -96,9 +105,11 @@ public interface Method { | |
private boolean mShouldCache = true; | ||
|
||
/** Whether or not this request has been canceled. */ | ||
// GuardedBy(mLock) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"). There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 :) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. */ | ||
|
@@ -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 | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)} | ||
*/ | ||
|
@@ -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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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 { | ||
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
@@ -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 | ||
|
@@ -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) { | ||
|
@@ -121,6 +132,10 @@ private synchronized T doGet(Long timeoutMs) | |
throw new TimeoutException(); | ||
} | ||
|
||
if (isCancelled()) { | ||
throw new CancellationException(); | ||
} | ||
|
||
return mResult; | ||
} | ||
|
||
|
@@ -129,7 +144,7 @@ public boolean isCancelled() { | |
if (mRequest == null) { | ||
return false; | ||
} | ||
return mRequest.isCanceled(); | ||
return mRequestCanceled; | ||
} | ||
|
||
@Override | ||
|
@@ -149,5 +164,11 @@ public synchronized void onErrorResponse(VolleyError error) { | |
mException = error; | ||
notifyAll(); | ||
} | ||
|
||
@Override | ||
public synchronized void onRequestCanceled() { | ||
mRequestCanceled = true; | ||
notifyAll(); | ||
} | ||
} | ||
|
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.
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.
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.
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.