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

Gracefully handle parse failure with locking #4114

Merged
merged 1 commit into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

**Fixed**

- Nothing yet!
- Ensure that exceptions thrown from failure to parse method annotations can be observed by multiple threads/callers. Previously only the first caller would see the actual parsing exception and other callers would get a cryptic `ClassCastException`.


## [2.10.0] - 2024-03-18
Expand Down
86 changes: 86 additions & 0 deletions retrofit/java-test/src/test/java/retrofit2/RetrofitTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static retrofit2.AnnotationArraySubject.assertThat;
Expand Down Expand Up @@ -1716,4 +1717,89 @@ public void argumentCapture() throws Exception {

assertEquals("/?i=201", server.takeRequest().getPath());
}

@Test
public void annotationParsingFailureObservedByWaitingThreads() throws InterruptedException {
AtomicInteger fails = new AtomicInteger();
CountDownLatch startedParsing = new CountDownLatch(1);
CountDownLatch failParsing = new CountDownLatch(1);
RuntimeException failure = new RuntimeException("boom!");
Retrofit retrofit =
new Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(
new Converter.Factory() {
@Nullable
@Override
public Converter<ResponseBody, ?> responseBodyConverter(
Type type, Annotation[] annotations, Retrofit retrofit) {
startedParsing.countDown();
try {
failParsing.await();
} catch (InterruptedException e) {
throw new AssertionError(e);
}
fails.incrementAndGet();
throw failure;
}
})
.build();
Annotated service = retrofit.create(Annotated.class);

AtomicReference<RuntimeException> result1 = new AtomicReference<>();
Thread thread1 =
new Thread(
() -> {
try {
service.method();
} catch (RuntimeException e) {
result1.set(e);
}
});
thread1.start();

// Wait for thread1 to enter the converter. This means it has inserted and taken a lock on
// parsing for the method.
startedParsing.await();

CountDownLatch thread2Locked = new CountDownLatch(1);
AtomicReference<RuntimeException> result2 = new AtomicReference<>();
Thread thread2 =
new Thread(
() -> {
try {
thread2Locked.countDown();
service.method();
} catch (RuntimeException e) {
result2.set(e);
}
});
thread2.start();
thread2Locked.await();

// Wait for thread2 to lock on the shared object. This should be pretty fast after the last
// signal, but we have no way of knowing for sure it happened.
Thread.sleep(1_000);

failParsing.countDown();
thread1.join();
thread2.join();

RuntimeException failure1 = result1.get();
assertThat(failure1).isInstanceOf(IllegalArgumentException.class);
assertThat(failure1).hasCauseThat().isSameInstanceAs(failure);

RuntimeException failure2 = result2.get();
assertThat(failure2).isInstanceOf(IllegalArgumentException.class);
assertThat(failure2).hasCauseThat().isSameInstanceAs(failure);

// Importantly, even though the second thread was locked waiting on the first, failure of the
// first thread caused the second thread to retry parsing.
assertThat(fails.get()).isEqualTo(2);

// Make sure now that both threads have released the lock, new callers also retry.
RuntimeException failure3 = assertThrows(IllegalArgumentException.class, service::method);
assertThat(failure3).hasCauseThat().isSameInstanceAs(failure);
assertThat(fails.get()).isEqualTo(3);
}
}
69 changes: 42 additions & 27 deletions retrofit/src/main/java/retrofit2/Retrofit.java
Original file line number Diff line number Diff line change
Expand Up @@ -212,38 +212,53 @@ private void validateServiceInterface(Class<?> service) {
}

ServiceMethod<?> loadServiceMethod(Class<?> service, Method method) {
// Note: Once we are minSdk 24 this whole method can be replaced by computeIfAbsent.
Object lookup = serviceMethodCache.get(method);
while (true) {
// Note: Once we are minSdk 24 this whole method can be replaced by computeIfAbsent.
Object lookup = serviceMethodCache.get(method);

if (lookup instanceof ServiceMethod<?>) {
// Happy path: method is already parsed into the model.
return (ServiceMethod<?>) lookup;
}
if (lookup instanceof ServiceMethod<?>) {
// Happy path: method is already parsed into the model.
return (ServiceMethod<?>) lookup;
}

if (lookup == null) {
// Map does not contain any value. Try to put in a lock for this method. We MUST synchronize
// on the lock before it is visible to others via the map to signal we are doing the work.
Object lock = new Object();
synchronized (lock) {
lookup = serviceMethodCache.putIfAbsent(method, lock);
if (lookup == null) {
// On successful lock insertion, perform the work and update the map before releasing.
// Other threads may be waiting on lock now and will expect the parsed model.
ServiceMethod<Object> result = ServiceMethod.parseAnnotations(this, service, method);
serviceMethodCache.put(method, result);
return result;
if (lookup == null) {
// Map does not contain any value. Try to put in a lock for this method. We MUST synchronize
// on the lock before it is visible to others via the map to signal we are doing the work.
Object lock = new Object();
synchronized (lock) {
lookup = serviceMethodCache.putIfAbsent(method, lock);
if (lookup == null) {
// On successful lock insertion, perform the work and update the map before releasing.
// Other threads may be waiting on lock now and will expect the parsed model.
ServiceMethod<Object> result;
try {
result = ServiceMethod.parseAnnotations(this, service, method);
} catch (Throwable e) {
// Remove the lock on failure. Any other locked threads will retry as a result.
serviceMethodCache.remove(method);
throw e;
}
Comment on lines +236 to +240
Copy link
Member Author

Choose a reason for hiding this comment

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

Change is here

serviceMethodCache.put(method, result);
return result;
}
}
}
}

// Either the initial lookup or the attempt to put our lock in the map has returned someone
// else's lock. This means they are doing the parsing, and will update the map before releasing
// the lock. Once we can take the lock, the map is guaranteed to contain the model.
// Note: There's a chance that our effort to put a lock into the map has actually returned a
// finished model instead of a lock. In that case this code will perform a pointless lock and
// redundant lookup in the map of the same instance. This is rare, and ultimately harmless.
synchronized (lookup) {
return (ServiceMethod<?>) serviceMethodCache.get(method);
// Either the initial lookup or the attempt to put our lock in the map has returned someone
// else's lock. This means they are doing the parsing, and will update the map before
// releasing
// the lock. Once we can take the lock, the map is guaranteed to contain the model or null.
// Note: There's a chance that our effort to put a lock into the map has actually returned a
// finished model instead of a lock. In that case this code will perform a pointless lock and
// redundant lookup in the map of the same instance. This is rare, and ultimately harmless.
synchronized (lookup) {
Object result = serviceMethodCache.get(method);
if (result == null) {
// The other thread failed its parsing. We will retry (and probably also fail).
continue;
}
Comment on lines +256 to +259
Copy link
Member Author

Choose a reason for hiding this comment

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

And here (plus the enclosing while (true))

Choose a reason for hiding this comment

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

I am thinking about whether this situation is possible: thread 1 put its lock1to the cache, granted the right to parse the method, but failed and threw an exception. Then, thread 2, which was waiting for thread 1 to parse the method, synchronize lock1 successfully, find the result is null, continue the while loop and put its lock2 to the cache. Then, Thread 3 synchronize lock1. However, the result retrieved from the cache by thread 3 is lock2 instead of null. Thus, thread 3 try to cast object to ServiceMethod and a ClassCastException happens. Can we check result instanceof ServiceMethod<?> here? What's more, can we check lookup instanceof ServiceMethod<?> before synchronize lookup to eliminate the pointless lock and redundant lookup you've metioned in comment(line 253)? Thanks.

return (ServiceMethod<?>) result;
}
}
}

Expand Down