diff --git a/core/src/main/java/io/grpc/Context.java b/core/src/main/java/io/grpc/Context.java new file mode 100644 index 00000000000..4e37ad06115 --- /dev/null +++ b/core/src/main/java/io/grpc/Context.java @@ -0,0 +1,768 @@ +/* + * Copyright 2015, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.grpc; + +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.annotation.Nullable; + + +/** + * A context propagation mechanism which carries deadlines, cancellation signals, + * and other scoped values across API boundaries and between threads. Examples of functionality + * propagated via context include: + * + * + *

Context objects make their state available by being attached to the executing thread using + * a {@link ThreadLocal}. The context object bound to a thread is considered {@link #current()}. + * Context objects are immutable and inherit state from their parent. To add or overwrite the + * current state a new context object must be created and then attached to the thread replacing the + * previously bound context. For example: + *

+ *   Context withCredential = Context.current().withValue(CRED_KEY, cred);
+ *   executorService.execute(withCredential.wrap(new Runnable() {
+ *     public void run() {
+ *        readUserRecords(userId, CRED_KEY.get());
+ *     }
+ *   }));
+
+ * 
+ * + *

Context objects will cascade cancellation from their parent and propagate it to their + * children. You can add a {@link CancellationListener} to a context to be notified when it or + * one of its ancestors has been cancelled. Cancellation does not release the state stored by + * a context and it's perfectly valid to {@link #attach()} an already cancelled context to a + * thread to make it current. To cancel a context (and its descendants) you first create a + * {@link CancellableContext} and when you need to signal cancellation call + * {@link CancellableContext#cancel} or {@link CancellableContext#detachAndCancel}. For example: + *

+ *   CancellableContext withCancellation = Context.current().withCancellation();
+ *   try {
+   *   executorService.execute(withCancellation.wrap(new Runnable() {
+ *       public void run() {
+ *         while (waitingForData() && !Context.current().isCancelled()) {}
+ *       }
+ *     });
+ *     doSomeWork();
+ *   } catch (Throwable t) {
+ *      withCancellation.cancel(t);
+ *   }
+ * 
+ * + * + *

Notes and cautions on use: + *

+ */ +public class Context { + + private static final Logger LOG = Logger.getLogger(Context.class.getName()); + + /** + * Use a shared resource to retain the {@link ScheduledExecutorService} used to + * implement deadline based context cancellation. This allows the executor to be + * shutdown if its not in use thereby allowing Context to be unloaded. + */ + static final SharedResourceHolder.Resource SCHEDULER = + new SharedResourceHolder.Resource() { + private static final String name = "context-scheduler"; + @Override + public ScheduledExecutorService create() { + return Executors.newScheduledThreadPool(1, new ThreadFactoryBuilder() + .setNameFormat(name + "-%d") + .setDaemon(true) + .build()); + } + + @Override + public void close(ScheduledExecutorService instance) { + instance.shutdown(); + } + + @Override + public String toString() { + return name; + } + }; + + /** + * Stack of context objects which is used to record attach & detach history on a thread. + */ + private static final ThreadLocal> contextStack = + new ThreadLocal>() { + @Override + protected ArrayDeque initialValue() { + return new ArrayDeque(); + } + }; + + private static final Object[][] EMPTY_ENTRIES = new Object[0][2]; + + /** + * The logical root context which is {@link #current()} if no other context is bound. This context + * is not cancellable and so will not cascade cancellation or retain listeners. + */ + public static final Context ROOT = new Context(null); + + /** + * Create a {@link Key} with the given name. + */ + public static Key key(String name) { + return new Key(name); + } + + /** + * Create a {@link Key} with the given name and default value. + */ + public static Key keyWithDefault(String name, T defaultValue) { + return new Key(name, defaultValue); + } + + /** + * Return the context associated with the current thread, will never return {@code null} as + * the {@link #ROOT} context is implicitly associated with all threads. + * + *

Will never return {@link CancellableContext} even if one is attached, instead a + * {@link Context} is returned with the same properties and lifetime. This is to avoid + * code stealing the ability to cancel arbitrarily. + */ + public static Context current() { + ArrayDeque stack = contextStack.get(); + if (stack.isEmpty()) { + return ROOT; + } + return stack.peekLast(); + } + + private final Context parent; + private final Object[][] keyValueEntries; + private final boolean cascadesCancellation; + private ArrayList listeners; + private CancellationListener parentListener = new CancellationListener() { + @Override + public void cancelled(Context context) { + if (Context.this instanceof CancellableContext) { + // Record cancellation with its cause. + ((CancellableContext) Context.this).cancel(context.cause()); + } else { + notifyAndClearListeners(); + } + } + }; + + /** + * Construct a context that cannot be cancelled and will not cascade cancellation from its parent. + */ + private Context(Context parent) { + this.parent = parent; + keyValueEntries = EMPTY_ENTRIES; + cascadesCancellation = false; + } + + /** + * Construct a context that cannot be cancelled but will cascade cancellation from its parent if + * it is cancellable. + */ + private Context(Context parent, Object[][] keyValueEntries) { + this.parent = parent; + this.keyValueEntries = keyValueEntries; + cascadesCancellation = true; + } + + /** + * Create a new context which is independently cancellable and also cascades cancellation from + * its parent. Callers should ensure that either {@link CancellableContext#cancel(Throwable)} + * or {@link CancellableContext#detachAndCancel(Throwable)} are called to notify listeners and + * release the resources associated with them. + * + *

Sample usage: + *

+   *   Context.CancellableContext withCancellation = Context.current().withCancellation();
+   *   try {
+   *     executorService.execute(withCancellation.wrap(new Runnable() {
+   *       public void run() {
+   *         Context current = Context.current();
+   *         while (!current.isCancelled()) {
+   *           keepWorking();
+   *         }
+   *       }
+   *     });
+   *     doSomethingRelatedWork();
+   *   } catch (Throwable t) {
+   *     withCancellation.cancel(t);
+   *   }
+   * 
+ */ + public CancellableContext withCancellation() { + return new CancellableContext(this); + } + + /** + * Create a new context which will cancel itself after an absolute deadline expressed as + * nanoseconds in the {@link System#nanoTime()} clock. The returned context will cascade + * cancellation of its parent. Callers may explicitly cancel the returned context prior to + * the deadline just as for {@link #withCancellation()}, + * + *

It is recommended that callers only use this method when propagating a derivative of + * a received existing deadline. When establishing a new deadline, {@link #withDeadlineAfter} + * is the better mechanism. + */ + public CancellableContext withDeadlineNanoTime(long deadlineNanoTime) { + return withDeadlineAfter(deadlineNanoTime - System.nanoTime(), TimeUnit.NANOSECONDS); + } + + /** + * Create a new context which will cancel itself after the given {@code duration} from now. + * The returned context will cascade cancellation of its parent. Callers may explicitly cancel + * the returned context prior to the deadline just as for {@link #withCancellation()}, + * + *

Sample usage: + *

+   *   Context.CancellableContext withDeadline = Context.current().withDeadlineAfter(5,
+   *       TimeUnit.SECONDS);
+   *   executorService.execute(withDeadline.wrap(new Runnable() {
+   *     public void run() {
+   *       Context current = Context.current();
+   *       while (!current.isCancelled()) {
+   *         keepWorking();
+   *       }
+   *     }
+   *   });
+   * 
+ */ + public CancellableContext withDeadlineAfter(long duration, TimeUnit unit) { + Preconditions.checkArgument(duration >= 0, "duration must be greater than or equal to 0"); + Preconditions.checkNotNull(unit, "unit"); + return new CancellableContext(this, unit.toNanos(duration)); + } + + /** + * Create a new context with the given key value set. The new context will cascade cancellation + * from its parent. + * +
+   *   Context withCredential = Context.current().withValue(CRED_KEY, cred);
+   *   executorService.execute(withCredential.wrap(new Runnable() {
+   *     public void run() {
+   *        readUserRecords(userId, CRED_KEY.get());
+   *     }
+   *   }));
+   * 
+ * + */ + public Context withValue(Key k1, V v1) { + return new Context(this, new Object[][]{{k1, v1}}); + } + + /** + * Create a new context with the given key value set. The new context will cascade cancellation + * from its parent. + */ + public Context withValues(Key k1, V1 v1, Key k2, V2 v2) { + return new Context(this, new Object[][]{{k1, v1}, {k2, v2}}); + } + + /** + * Create a new context with the given key value set. The new context will cascade cancellation + * from its parent. + */ + public Context withValues(Key k1, V1 v1, Key k2, V2 v2, Key k3, V3 v3) { + return new Context(this, new Object[][]{{k1, v1}, {k2, v2}, {k3, v3}}); + } + + /** + * Create a new context which copies the values of this context but does not propagate its + * cancellation and is its own independent root for cancellation. + */ + public CancellableContext fork() { + return new Context(this).withCancellation(); + } + + boolean canBeCancelled() { + // A context is cancellable if it cascades from its parent and its parent is + // cancellable. + return (cascadesCancellation && this.parent != null && this.parent.canBeCancelled()); + } + + /** + * Attach this context to the thread and make it {@link #current}, the previously current context + * will be restored when detach is called. It is allowed to attach contexts where + * {@link #isCancelled()} is {@code true}. + */ + public void attach() { + contextStack.get().addLast(this); + } + + // Visible for testing + boolean isCurrent() { + return current() == this; + } + + /** + * Detach the current context from the thread and restore the context that was previously + * attached to the thread as the 'current' context. + * + * @throws java.lang.IllegalStateException if this context is not {@link #current()}. + */ + public void detach() { + ArrayDeque stack = contextStack.get(); + if (stack.isEmpty()) { + if (this == ROOT) { + throw new IllegalStateException("Cannot detach root"); + } else { + throw new IllegalStateException("Cannot detach non-root context when root is current"); + } + } + if (stack.peekLast() != this) { + throw new IllegalStateException("Cannot detach a context that is not current"); + } + stack.removeLast(); + } + + /** + * Is this context cancelled. + */ + public boolean isCancelled() { + if (parent == null || !cascadesCancellation) { + return false; + } else { + return parent.isCancelled(); + } + } + + /** + * If a context {@link #isCancelled()} then return the cause of the cancellation or + * {@code null} if context was cancelled without a cause. If the context is not yet cancelled + * will always return {@code null}. + * + *

The cause is provided for informational purposes only and implementations should generally + * assume that it has already been handled and logged properly. + */ + @Nullable + public Throwable cause() { + if (parent == null || !cascadesCancellation) { + return null; + } else { + return parent.cause(); + } + } + + /** + * Add a listener that will be notified when the context becomes cancelled. + */ + public void addListener(final CancellationListener cancellationListener, + final Executor executor) { + Preconditions.checkNotNull(cancellationListener); + Preconditions.checkNotNull(executor); + if (canBeCancelled()) { + ExecutableListener executableListener = + new ExecutableListener(executor, cancellationListener); + synchronized (this) { + if (isCancelled()) { + executableListener.deliver(); + } else { + if (listeners == null) { + // Now that we have a listener we need to listen to our parent so + // we can cascade listener notification. + listeners = new ArrayList(); + listeners.add(executableListener); + parent.addListener(parentListener, MoreExecutors.directExecutor()); + } else { + listeners.add(executableListener); + } + } + } + } else { + // Discussion point: Should we throw or suppress. + } + } + + /** + * Remove a {@link CancellationListener}. + */ + public void removeListener(CancellationListener cancellationListener) { + synchronized (this) { + if (listeners != null) { + for (int i = listeners.size() - 1; i >= 0; i--) { + if (listeners.get(i).listener == cancellationListener) { + listeners.remove(i); + // Just remove the first matching listener, given that we allow duplicate adds we should + // allow for duplicates after remove. + break; + } + } + // We have no listeners so no need to listen to our parent + if (listeners.isEmpty()) { + parent.removeListener(parentListener); + listeners = null; + } + } + } + } + + /** + * Notify all listeners that this context has been cancelled and immediately release + * any reference to them so that they may be garbage collected. + */ + void notifyAndClearListeners() { + ArrayList tmpListeners; + synchronized (this) { + if (listeners == null) { + return; + } + tmpListeners = listeners; + listeners = null; + } + for (int i = 0; i < tmpListeners.size(); i++) { + try { + tmpListeners.get(i).deliver(); + } catch (Throwable t) { + LOG.log(Level.INFO, "Exception notifying context listener", t); + } + } + parent.removeListener(parentListener); + } + + // Used in tests to ensure that listeners are defined and released based on + // cancellation propagation. It's very important to ensure that we do not + // accidentally retain listeners. + int listenerCount() { + synchronized (this) { + return listeners == null ? 0 : listeners.size(); + } + } + + /** + * Wrap a {@link Runnable} so that it executes with this context as the {@link #current} context. + */ + public Runnable wrap(final Runnable r) { + return new Runnable() { + @Override + public void run() { + attach(); + try { + r.run(); + } finally { + detach(); + } + } + }; + } + + /** + * Wrap a {@link Callable} so that it executes with this context as the {@link #current} context. + */ + public Callable wrap(final Callable c) { + return new Callable() { + @Override + public C call() throws Exception { + attach(); + try { + return c.call(); + } finally { + detach(); + } + } + }; + } + + /** + * Lookup the value for a key in the context inheritance chain. + */ + private Object lookup(Key key) { + for (int i = 0; i < keyValueEntries.length; i++) { + if (key.equals(keyValueEntries[i][0])) { + return keyValueEntries[i][1]; + } + } + if (parent == null) { + return null; + } + return parent.lookup(key); + } + + /** + * A context which inherits cancellation from its parent but which can also be independently + * cancelled and which will propagate cancellation to its descendants. + */ + public static final class CancellableContext extends Context { + + private boolean cancelled; + private Throwable cause; + private final Context dummy; + private ScheduledFuture scheduledFuture; + + /** + * Create a cancellable context that does not have a deadline. + */ + private CancellableContext(Context parent) { + super(parent, EMPTY_ENTRIES); + // Create a dummy that inherits from this to attach and detach so that you cannot retrieve a + // cancellable context from Context.current() + dummy = new Context(this, EMPTY_ENTRIES); + } + + /** + * Create a cancellable context that has a deadline. + */ + private CancellableContext(Context parent, long delayNanos) { + this(parent); + final ScheduledExecutorService scheduler = SharedResourceHolder.get(SCHEDULER); + scheduler.schedule(new Runnable() { + @Override + public void run() { + try { + cancel(new TimeoutException("context timed out")); + } finally { + SharedResourceHolder.release(SCHEDULER, scheduler); + } + } + }, delayNanos, TimeUnit.NANOSECONDS); + } + + + @Override + public void attach() { + dummy.attach(); + } + + @Override + public void detach() { + dummy.detach(); + } + + @Override + public boolean isCurrent() { + return dummy.isCurrent(); + } + + /** + * Attach this context to the thread and return a {@link AutoCloseable} that can be + * used with try-with-resource statements to properly {@link #detach} and {@link #cancel} + * the context on completion. + * + * @return a {@link java.io.Closeable} which can be used with try-with-resource blocks. + */ + public Closeable attachAsCloseable() { + attach(); + return new Closeable() { + @Override + public void close() throws IOException { + detachAndCancel(null); + } + }; + } + + /** + * Cancel this context and optionally provide a cause for the cancellation. This + * will trigger notification of listeners. + * + * @return {@code true} if this context cancelled the context and notified listeners, + * {@code false} if the context was already cancelled. + */ + public boolean cancel(@Nullable Throwable cause) { + boolean triggeredCancel = false; + synchronized (this) { + if (!cancelled) { + cancelled = true; + if (scheduledFuture != null) { + // If we have a scheduled cancellation pending attempt to cancel it. + scheduledFuture.cancel(false); + scheduledFuture = null; + } + this.cause = cause; + triggeredCancel = true; + } + } + if (triggeredCancel) { + notifyAndClearListeners(); + } + return triggeredCancel; + } + + /** + * Cancel this context and detach it from the current context from the thread and restore the + * context that was previously attached to the thread as the 'current' context. + * + * @throws java.lang.IllegalStateException if this context is not {@link #current()}. + */ + public void detachAndCancel(@Nullable Throwable cause) { + try { + detach(); + } finally { + cancel(cause); + } + } + + @Override + protected boolean canBeCancelled() { + return true; + } + + @Override + public boolean isCancelled() { + synchronized (this) { + if (cancelled) { + return true; + } + } + // Detect cancellation of parent in the case where we have no listeners and + // record it. + if (super.isCancelled()) { + cancel(super.cause()); + return true; + } + return false; + } + + @Nullable + @Override + public Throwable cause() { + if (isCancelled()) { + return cause; + } + return null; + } + } + + /** + * A listener notified on context cancellation. + */ + public interface CancellationListener { + /** + * @param context the newly cancelled context. + */ + public void cancelled(Context context); + } + + /** + * Key for indexing values stored in a context. + */ + public static class Key { + + private final String name; + private final T defaultValue; + + Key(String name) { + this(name, null); + } + + Key(String name, T defaultValue) { + this.name = Preconditions.checkNotNull(name); + this.defaultValue = defaultValue; + } + + /** + * Get the value from the {@link #current()} context for this key. + */ + @SuppressWarnings("unchecked") + public T get() { + return get(Context.current()); + } + + /** + * Get the value from the specified context for this key. + */ + @SuppressWarnings("unchecked") + public T get(Context context) { + T value = (T) context.lookup(this); + return value == null ? defaultValue : value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Key key = (Key) o; + + return key.name.equals(this.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + } + + /** + * Stores listener & executor pair. + */ + private class ExecutableListener implements Runnable { + private final Executor executor; + private final CancellationListener listener; + + private ExecutableListener(Executor executor, CancellationListener listener) { + this.executor = executor; + this.listener = listener; + } + + private void deliver() { + executor.execute(this); + } + + @Override + public void run() { + listener.cancelled(Context.this); + } + } +} diff --git a/core/src/test/java/io/grpc/ContextTest.java b/core/src/test/java/io/grpc/ContextTest.java new file mode 100644 index 00000000000..35d5ac12fbf --- /dev/null +++ b/core/src/test/java/io/grpc/ContextTest.java @@ -0,0 +1,410 @@ +/* + * Copyright 2015, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.grpc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.common.util.concurrent.MoreExecutors; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Tests for {@link Context}. + */ +@RunWith(JUnit4.class) +public class ContextTest { + + private static final Context.Key PET = Context.key("pet"); + private static final Context.Key FOOD = Context.keyWithDefault("food", "lasagna"); + private static final Context.Key COLOR = Context.key("color"); + private static final Context.Key OBSERVED = Context.key("observed"); + + private Context listenerNotifedContext; + private CountDownLatch deadlineLatch = new CountDownLatch(1); + private Context.CancellationListener cancellationListener = new Context.CancellationListener() { + @Override + public void cancelled(Context context) { + listenerNotifedContext = context; + deadlineLatch.countDown(); + } + }; + + private CountDownLatch observableLatch = new CountDownLatch(1); + private Object observed; + private Runnable runner = new Runnable() { + @Override + public void run() { + observed = OBSERVED.get(); + observableLatch.countDown(); + } + }; + + private ExecutorService executorService = Executors.newCachedThreadPool(); + + @Before + public void setUp() throws Exception { + // Detach all contexts back to the root. + while (Context.current() != Context.ROOT) { + Context.current().detach(); + } + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void rootIsInitialContext() { + assertNotNull(Context.ROOT); + assertTrue(Context.ROOT.isCurrent()); + } + + @Test + public void rootIsAlwaysBound() { + Context root = Context.current(); + try { + root.detach(); + } catch (IllegalStateException ise) { + // Expected + assertTrue(Context.ROOT.isCurrent()); + return; + } + fail("Attempting to detach root should fail"); + } + + @Test + public void rootCanBeAttached() { + Context root = Context.current(); + Context.CancellableContext fork = root.fork(); + fork.attach(); + root.attach(); + assertTrue(root.isCurrent()); + root.detach(); + assertTrue(fork.isCurrent()); + fork.detach(); + assertTrue(root.isCurrent()); + } + + @Test + public void rootCanNeverHaveAListener() { + Context root = Context.current(); + root.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertEquals(0, root.listenerCount()); + } + + @Test + public void attachedCancellableContextCannotBeCastFromCurrent() { + Context initial = Context.current(); + Context.CancellableContext base = Context.current().withCancellation(); + base.attach(); + assertFalse(Context.current() instanceof Context.CancellableContext); + assertNotSame(initial, Context.current()); + base.detachAndCancel(null); + assertTrue(initial.isCurrent()); + } + + @Test + public void detachingNonCurrentThrowsIllegalStateException() { + Context base = Context.current().fork(); + try { + base.detach(); + } catch (IllegalStateException ise) { + return; + } + fail("Expected exception"); + } + + @Test + public void detachUnwindsAttach() { + Context base = Context.current().fork(); + Context child = base.withValue(COLOR, "red"); + Context grandchild = child.withValue(COLOR, "blue"); + base.attach(); + base.attach(); + child.attach(); + base.attach(); + grandchild.attach(); + assertTrue(grandchild.isCurrent()); + grandchild.detach(); + assertTrue(base.isCurrent()); + base.detach(); + assertTrue(child.isCurrent()); + child.detach(); + assertTrue(base.isCurrent()); + base.detach(); + assertTrue(base.isCurrent()); + base.detach(); + assertTrue(Context.ROOT.isCurrent()); + } + + @Test + public void valuesAndOverrides() { + Context base = Context.current().withValue(PET, "dog"); + Context child = base.withValues(PET, null, FOOD, "cheese"); + + base.attach(); + + assertEquals("dog", PET.get()); + assertEquals("lasagna", FOOD.get()); + assertNull(COLOR.get()); + + child.attach(); + + assertNull(PET.get()); + assertEquals("cheese", FOOD.get()); + assertNull(COLOR.get()); + + child.detach(); + + // Should have values from base + assertEquals("dog", PET.get()); + assertEquals("lasagna", FOOD.get()); + assertNull(COLOR.get()); + + base.detach(); + + assertNull(PET.get()); + assertEquals("lasagna", FOOD.get()); + assertNull(COLOR.get()); + } + + @Test + public void cancelReturnsFalseIfAlreadyCancelled() { + Context.CancellableContext base = Context.current().withCancellation(); + assertTrue(base.cancel(null)); + assertTrue(base.isCancelled()); + assertFalse(base.cancel(null)); + } + + @Test + public void notifyListenerOnCancel() { + Context.CancellableContext base = Context.current().withCancellation(); + base.attach(); + base.addListener(cancellationListener, MoreExecutors.directExecutor()); + base.detachAndCancel(null); + assertSame(base, listenerNotifedContext); + } + + @Test + public void cascadingCancellationNotifiesChild() { + // Root is not cancellable so we can't cascade from it + Context.CancellableContext base = Context.current().withCancellation(); + assertEquals(0, base.listenerCount()); + Context child = base.withValue(FOOD, "lasagna"); + assertEquals(0, child.listenerCount()); + child.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertEquals(1, child.listenerCount()); + assertEquals(1, base.listenerCount()); // child is now listening to base + assertFalse(base.isCancelled()); + assertFalse(child.isCancelled()); + IllegalStateException cause = new IllegalStateException(); + base.cancel(cause); + assertTrue(base.isCancelled()); + assertSame(cause, base.cause()); + assertSame(child, listenerNotifedContext); + assertTrue(child.isCancelled()); + assertSame(cause, child.cause()); + assertEquals(0, base.listenerCount()); + assertEquals(0, child.listenerCount()); + } + + @Test + public void cancellableContextCascadesFromCancellableParent() { + // Root is not cancellable so we can't cascade from it + Context.CancellableContext base = Context.current().withCancellation(); + Context child = base.withCancellation(); + child.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertFalse(base.isCancelled()); + assertFalse(child.isCancelled()); + IllegalStateException cause = new IllegalStateException(); + base.cancel(cause); + assertTrue(base.isCancelled()); + assertSame(cause, base.cause()); + assertSame(child, listenerNotifedContext); + assertTrue(child.isCancelled()); + assertSame(cause, child.cause()); + assertEquals(0, base.listenerCount()); + assertEquals(0, child.listenerCount()); + } + + @Test + public void nonCascadingCancellationDoesNotNotifyForked() { + Context.CancellableContext base = Context.current().withCancellation(); + Context fork = base.fork(); + fork.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertTrue(base.cancel(null)); + assertNull(listenerNotifedContext); + assertFalse(fork.isCancelled()); + assertEquals(1, fork.listenerCount()); + } + + @Test + public void testWrapRunnable() throws Exception { + Context base = Context.current().withValue(OBSERVED, "cat"); + + executorService.execute(base.wrap(runner)); + observableLatch.await(); + assertEquals("cat", observed); + + observableLatch = new CountDownLatch(1); + executorService.execute(Context.current().wrap(runner)); + observableLatch.await(); + assertNull(observed); + } + + @Test + public void typicalTryFinallyHandling() throws Exception { + Context base = Context.current().withValue(COLOR, "blue"); + base.attach(); + try { + assertTrue(base.isCurrent()); + // Do something + } finally { + base.detach(); + } + assertFalse(base.isCurrent()); + } + + @Test + public void typicalCancellableTryCatchFinallyHandling() throws Exception { + Context.CancellableContext base = Context.current().withCancellation(); + base.attach(); + try { + // Do something + throw new IllegalStateException("Argh"); + } catch (IllegalStateException ise) { + base.cancel(ise); + } finally { + base.detachAndCancel(null); + } + assertTrue(base.isCancelled()); + assertNotNull(base.cause()); + } + + + /* + public void testTryWithResource() throws Exception { + Context.CancellableContext base = Context.current().withCancellation(); + + try (Closeable c = base.attachAsCloseable()) { + // Do something + throw new IllegalStateException("Argh"); + } catch (IllegalStateException ise) { + // Don't capture exception + } + assertTrue(base.isCancelled()); + assertNull(base.cause()); + } + + public void testTryWithResource() throws Exception { + Context.CancellableContext base = Context.current().withCancellation(); + + try (Closeable c = base.attachAsCloseable()) { + // Do something + throw new IllegalStateException("Argh"); + } catch (IllegalStateException ise) { + base.cancel(ise); + } + assertTrue(base.isCancelled()); + assertNotNull(base.cause()); + } + */ + + @Test + public void absoluteDeadlineTriggersAndPropagates() throws Exception { + Context base = Context.current().withDeadlineNanoTime(System.nanoTime() + + TimeUnit.SECONDS.toNanos(1)); + Context child = base.withValue(FOOD, "lasagna"); + child.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertFalse(base.isCancelled()); + assertFalse(child.isCancelled()); + deadlineLatch.await(2, TimeUnit.SECONDS); + assertTrue(base.isCancelled()); + assertTrue(base.cause() instanceof TimeoutException); + assertSame(child, listenerNotifedContext); + assertTrue(child.isCancelled()); + assertSame(base.cause(), child.cause()); + } + + @Test + public void relativeDeadlineTriggersAndPropagates() throws Exception { + Context base = Context.current().withDeadlineAfter(1, TimeUnit.SECONDS); + Context child = base.withValue(FOOD, "lasagna"); + child.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertFalse(base.isCancelled()); + assertFalse(child.isCancelled()); + deadlineLatch.await(2, TimeUnit.SECONDS); + assertTrue(base.isCancelled()); + assertTrue(base.cause() instanceof TimeoutException); + assertSame(child, listenerNotifedContext); + assertTrue(child.isCancelled()); + assertSame(base.cause(), child.cause()); + } + + @Test + public void innerDeadlineCompletesBeforeOuter() throws Exception { + Context base = Context.current().withDeadlineAfter(2, TimeUnit.SECONDS); + Context child = base.withDeadlineAfter(1, TimeUnit.SECONDS); + child.addListener(cancellationListener, MoreExecutors.directExecutor()); + assertFalse(base.isCancelled()); + assertFalse(child.isCancelled()); + deadlineLatch.await(2, TimeUnit.SECONDS); + assertFalse(base.isCancelled()); + assertSame(child, listenerNotifedContext); + assertTrue(child.isCancelled()); + assertTrue(child.cause() instanceof TimeoutException); + + deadlineLatch = new CountDownLatch(1); + base.addListener(cancellationListener, MoreExecutors.directExecutor()); + deadlineLatch.await(2, TimeUnit.SECONDS); + assertTrue(base.isCancelled()); + assertTrue(base.cause() instanceof TimeoutException); + assertNotSame(base.cause(), child.cause()); + } +}