@Test public void contextCancellationCancelsStream() throws Exception { // Attach the context which is recorded when the call is created Context.CancellableContext cancellableContext = Context.current().withCancellation(); Context previous = cancellableContext.attach(); ClientCallImpl<Void, Void> call = new ClientCallImpl<Void, Void>( DESCRIPTOR, new SerializingExecutor(Executors.newSingleThreadExecutor()), CallOptions.DEFAULT, provider, deadlineCancellationExecutor) .setDecompressorRegistry(decompressorRegistry); previous.attach(); call.start(callListener, new Metadata()); Throwable t = new Throwable(); cancellableContext.cancel(t); verify(stream, times(1)).cancel(statusArgumentCaptor.capture()); verify(stream, times(1)).cancel(statusCaptor.capture()); assertEquals(Status.Code.CANCELLED, statusCaptor.getValue().getCode()); }
@Test public void contextDeadlineShouldNotOverrideSmallerMetadataTimeout() { long deadlineNanos = TimeUnit.SECONDS.toNanos(2); Context context = Context.current() .withDeadlineAfter(deadlineNanos, TimeUnit.NANOSECONDS, deadlineCancellationExecutor); context.attach(); CallOptions callOpts = CallOptions.DEFAULT.withDeadlineAfter(1, TimeUnit.SECONDS); ClientCallImpl<Void, Void> call = new ClientCallImpl<Void, Void>( DESCRIPTOR, MoreExecutors.directExecutor(), callOpts, provider, deadlineCancellationExecutor); Metadata headers = new Metadata(); call.start(callListener, headers); assertTrue(headers.containsKey(GrpcUtil.TIMEOUT_KEY)); Long timeout = headers.get(GrpcUtil.TIMEOUT_KEY); assertNotNull(timeout); long callOptsNanos = TimeUnit.SECONDS.toNanos(1); long deltaNanos = TimeUnit.MILLISECONDS.toNanos(400); assertTimeoutBetween(timeout, callOptsNanos - deltaNanos, callOptsNanos); }
@Test public void contextAlreadyCancelledNotifiesImmediately() throws Exception { // Attach the context which is recorded when the call is created Context.CancellableContext cancellableContext = Context.current().withCancellation(); Throwable cause = new Throwable(); cancellableContext.cancel(cause); Context previous = cancellableContext.attach(); ClientCallImpl<Void, Void> call = new ClientCallImpl<Void, Void>( DESCRIPTOR, new SerializingExecutor(Executors.newSingleThreadExecutor()), CallOptions.DEFAULT, provider, deadlineCancellationExecutor) .setDecompressorRegistry(decompressorRegistry); previous.attach(); final SettableFuture<Status> statusFuture = SettableFuture.create(); call.start( new ClientCall.Listener<Void>() { @Override public void onClose(Status status, Metadata trailers) { statusFuture.set(status); } }, new Metadata()); // Caller should receive onClose callback. Status status = statusFuture.get(5, TimeUnit.SECONDS); assertEquals(Status.Code.CANCELLED, status.getCode()); assertSame(cause, status.getCause()); // Following operations should be no-op. call.request(1); call.sendMessage(null); call.halfClose(); // Stream should never be created. verifyZeroInteractions(transport); try { call.sendMessage(null); fail("Call has been cancelled"); } catch (IllegalStateException ise) { // expected } }
@Override public void cancel(@Nullable String message, @Nullable Throwable cause) { if (cancelCalled) { return; } cancelCalled = true; try { // Cancel is called in exception handling cases, so it may be the case that the // stream was never successfully created. if (stream != null) { Status status = Status.CANCELLED; if (message != null) { status = status.withDescription(message); } if (cause != null) { status = status.withCause(cause); } if (message == null && cause == null) { // TODO(zhangkun83): log a warning with this exception once cancel() has been deleted from // ClientCall. status = status.withCause( new CancellationException("Client called cancel() without any detail")); } stream.cancel(status); } } finally { if (context != null) { context.removeListener(ClientCallImpl.this); } } }
/** Is this context cancelled. */ public boolean isCancelled() { if (parent == null || !cascadesCancellation) { return false; } else { return parent.isCancelled(); } }
/** * 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() { if (!canBeCancelled) { return; } ArrayList<ExecutableListener> tmpListeners; synchronized (this) { if (listeners == null) { return; } tmpListeners = listeners; listeners = null; } // Deliver events to non-child context listeners before we notify child contexts. We do this // to cancel higher level units of work before child units. This allows for a better error // handling paradigm where the higher level unit of work knows it is cancelled and so can // ignore errors that bubble up as a result of cancellation of lower level units. for (int i = 0; i < tmpListeners.size(); i++) { if (!(tmpListeners.get(i).listener instanceof ParentListener)) { tmpListeners.get(i).deliver(); } } for (int i = 0; i < tmpListeners.size(); i++) { if (tmpListeners.get(i).listener instanceof ParentListener) { tmpListeners.get(i).deliver(); } } parent.removeListener(parentListener); }
/** * 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}. * * <p>The cancellation cause is provided for informational purposes only and implementations * should generally assume that it has already been handled and logged properly. */ @Nullable public Throwable cancellationCause() { if (parent == null || !cascadesCancellation) { return null; } else { return parent.cancellationCause(); } }
@Test public void statusFromCancelled_returnStatusAsSetOnCtx() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); cancellableContext.cancel(Status.DEADLINE_EXCEEDED.withDescription("foo bar").asException()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.DEADLINE_EXCEEDED, status.getCode()); assertEquals("foo bar", status.getDescription()); }
@Test public void statusFromCancelled_returnCancelledIfCauseIsNull() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); cancellableContext.cancel(null); assertTrue(cancellableContext.isCancelled()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.CANCELLED, status.getCode()); }
@Override public void cancelled(Context context) { if (Context.this instanceof CancellableContext) { // Record cancellation with its cancellationCause. ((CancellableContext) Context.this).cancel(context.cancellationCause()); } else { notifyAndClearListeners(); } }
@Test public void statusFromCancelled_shouldReturnStatusWithCauseAttached() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); Throwable t = new Throwable(); cancellableContext.cancel(t); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.CANCELLED, status.getCode()); assertSame(t, status.getCause()); }
@Test public void interceptCall_restoresIfNextThrows() { Context origContext = Context.current(); try { interceptCall( uniqueContext, call, headers, new ServerCallHandler<Object, Object>() { @Override public ServerCall.Listener<Object> startCall( ServerCall<Object, Object> call, Metadata headers) { throw new RuntimeException(); } }); fail("Expected exception"); } catch (RuntimeException expected) { } assertSame(origContext, Context.current()); }
/** 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); }
/** * Detach the current context from the thread and attach the provided replacement. If this context * is not {@link #current()} a SEVERE message will be logged but the context to attach will still * be bound. */ public void detach(Context toAttach) { Preconditions.checkNotNull(toAttach); if (toAttach.attach() != this) { // Log a severe message instead of throwing an exception as the context to attach is assumed // to be the correct one and the unbalanced state represents a coding mistake in a lower // layer in the stack that cannot be recovered from here. LOG.log( Level.SEVERE, "Context was not attached when detaching", new Throwable().fillInStackTrace()); } }
/** This is a whitebox test, to verify a special case of the implementation. */ @Test public void statusFromCancelled_StatusUnknownShouldWork() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); Exception e = Status.UNKNOWN.asException(); cancellableContext.cancel(e); assertTrue(cancellableContext.isCancelled()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.UNKNOWN, status.getCode()); assertSame(e, status.getCause()); }
@Test public void statusFromCancelled_TimeoutExceptionShouldMapToDeadlineExceeded() { FakeClock fakeClock = new FakeClock(); Context.CancellableContext cancellableContext = Context.current() .withDeadlineAfter(100, TimeUnit.MILLISECONDS, fakeClock.scheduledExecutorService); fakeClock.forwardTime(System.nanoTime(), TimeUnit.NANOSECONDS); fakeClock.forwardMillis(100); assertTrue(cancellableContext.isCancelled()); assertThat(cancellableContext.cancellationCause(), instanceOf(TimeoutException.class)); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.DEADLINE_EXCEEDED, status.getCode()); assertEquals("context timed out", status.getDescription()); }
/** Remove a {@link CancellationListener}. */ public void removeListener(CancellationListener cancellationListener) { if (!canBeCancelled) { return; } 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; } } } }
ClientCallImpl( MethodDescriptor<ReqT, RespT> method, Executor executor, CallOptions callOptions, ClientTransportProvider clientTransportProvider, ScheduledExecutorService deadlineCancellationExecutor) { this.method = method; // If we know that the executor is a direct executor, we don't need to wrap it with a // SerializingExecutor. This is purely for performance reasons. // See https://github.com/grpc/grpc-java/issues/368 this.callExecutor = executor == directExecutor() ? new SerializeReentrantCallsDirectExecutor() : new SerializingExecutor(executor); // Propagate the context from the thread which initiated the call to all callbacks. this.parentContext = Context.current(); this.unaryRequest = method.getType() == MethodType.UNARY || method.getType() == MethodType.SERVER_STREAMING; this.callOptions = callOptions; this.clientTransportProvider = clientTransportProvider; this.deadlineCancellationExecutor = deadlineCancellationExecutor; }
@Test public void expiredDeadlineCancelsStream_Context() { fakeClock.forwardTime(System.nanoTime(), TimeUnit.NANOSECONDS); Context.current() .withDeadlineAfter(1000, TimeUnit.MILLISECONDS, deadlineCancellationExecutor) .attach(); ClientCallImpl<Void, Void> call = new ClientCallImpl<Void, Void>( DESCRIPTOR, MoreExecutors.directExecutor(), CallOptions.DEFAULT, provider, deadlineCancellationExecutor); call.start(callListener, new Metadata()); fakeClock.forwardMillis(TimeUnit.SECONDS.toMillis(1001)); verify(stream, times(1)).cancel(statusCaptor.capture()); assertEquals(Status.Code.DEADLINE_EXCEEDED, statusCaptor.getValue().getCode()); }
/** 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<ExecutableListener>(); listeners.add(executableListener); parent.addListener(parentListener, MoreExecutors.directExecutor()); } else { listeners.add(executableListener); } } } } }
@Test public void callerContextPropagatedToListener() throws Exception { // Attach the context which is recorded when the call is created final Context.Key<String> testKey = Context.key("testing"); Context.current().withValue(testKey, "testValue").attach(); ClientCallImpl<Void, Void> call = new ClientCallImpl<Void, Void>( DESCRIPTOR, new SerializingExecutor(Executors.newSingleThreadExecutor()), CallOptions.DEFAULT, provider, deadlineCancellationExecutor) .setDecompressorRegistry(decompressorRegistry); Context.ROOT.attach(); // Override the value after creating the call, this should not be seen by callbacks Context.current().withValue(testKey, "badValue").attach(); final AtomicBoolean onHeadersCalled = new AtomicBoolean(); final AtomicBoolean onMessageCalled = new AtomicBoolean(); final AtomicBoolean onReadyCalled = new AtomicBoolean(); final AtomicBoolean observedIncorrectContext = new AtomicBoolean(); final CountDownLatch latch = new CountDownLatch(1); call.start( new ClientCall.Listener<Void>() { @Override public void onHeaders(Metadata headers) { onHeadersCalled.set(true); checkContext(); } @Override public void onMessage(Void message) { onMessageCalled.set(true); checkContext(); } @Override public void onClose(Status status, Metadata trailers) { checkContext(); latch.countDown(); } @Override public void onReady() { onReadyCalled.set(true); checkContext(); } private void checkContext() { if (!"testValue".equals(testKey.get())) { observedIncorrectContext.set(true); } } }, new Metadata()); verify(stream).start(listenerArgumentCaptor.capture()); ClientStreamListener listener = listenerArgumentCaptor.getValue(); listener.onReady(); listener.headersRead(new Metadata()); listener.messageRead(new ByteArrayInputStream(new byte[0])); listener.messageRead(new ByteArrayInputStream(new byte[0])); listener.closed(Status.OK, new Metadata()); assertTrue(latch.await(5, TimeUnit.SECONDS)); assertTrue(onHeadersCalled.get()); assertTrue(onMessageCalled.get()); assertTrue(onReadyCalled.get()); assertFalse(observedIncorrectContext.get()); }
/** 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; }
/** Get the value from the {@link #current()} context for this key. */ @SuppressWarnings("unchecked") public T get() { return get(Context.current()); }
@Override public boolean isCurrent() { return uncancellableSurrogate.isCurrent(); }
@Override public void detach(Context toAttach) { uncancellableSurrogate.detach(toAttach); }
@Override public Context attach() { return uncancellableSurrogate.attach(); }
@Override public void start(final Listener<RespT> observer, Metadata headers) { checkState(stream == null, "Already started"); checkNotNull(observer, "observer"); checkNotNull(headers, "headers"); // Create the context final Deadline effectiveDeadline = min(callOptions.getDeadline(), parentContext.getDeadline()); if (effectiveDeadline != parentContext.getDeadline()) { context = parentContext.withDeadline(effectiveDeadline, deadlineCancellationExecutor); } else { context = parentContext.withCancellation(); } if (context.isCancelled()) { // Context is already cancelled so no need to create a real stream, just notify the observer // of cancellation via callback on the executor stream = NoopClientStream.INSTANCE; callExecutor.execute( new ContextRunnable(context) { @Override public void runInContext() { observer.onClose(statusFromCancelled(context), new Metadata()); } }); return; } final String compressorName = callOptions.getCompressor(); Compressor compressor = null; if (compressorName != null) { compressor = compressorRegistry.lookupCompressor(compressorName); if (compressor == null) { stream = NoopClientStream.INSTANCE; callExecutor.execute( new ContextRunnable(context) { @Override public void runInContext() { observer.onClose( Status.INTERNAL.withDescription( String.format("Unable to find compressor by name %s", compressorName)), new Metadata()); } }); return; } } else { compressor = Codec.Identity.NONE; } prepareHeaders(headers, callOptions, userAgent, decompressorRegistry, compressor); final boolean deadlineExceeded = effectiveDeadline != null && effectiveDeadline.isExpired(); if (!deadlineExceeded) { updateTimeoutHeaders( effectiveDeadline, callOptions.getDeadline(), parentContext.getDeadline(), headers); ClientTransport transport = clientTransportProvider.get(callOptions); stream = transport.newStream(method, headers); } else { stream = new FailingClientStream(DEADLINE_EXCEEDED); } if (callOptions.getAuthority() != null) { stream.setAuthority(callOptions.getAuthority()); } stream.setCompressor(compressor); stream.start(new ClientStreamListenerImpl(observer)); if (compressor != Codec.Identity.NONE) { stream.setMessageCompression(true); } // Delay any sources of cancellation after start(), because most of the transports are broken if // they receive cancel before start. Issue #1343 has more details // Propagate later Context cancellation to the remote side. context.addListener(this, directExecutor()); if (contextListenerShouldBeRemoved) { // Race detected! ClientStreamListener.closed may have been called before // deadlineCancellationFuture was set, thereby preventing the future from being cancelled. // Go ahead and cancel again, just to be sure it was cancelled. context.removeListener(this); } }
@Override public void execute(Runnable r) { e.execute(Context.current().wrap(r)); }
@Test public void interceptCall_basic() { Context origContext = Context.current(); final Object message = new Object(); final List<Integer> methodCalls = new ArrayList<Integer>(); final ServerCall.Listener<Object> listener = new ServerCall.Listener<Object>() { @Override public void onMessage(Object messageIn) { assertSame(message, messageIn); assertSame(uniqueContext, Context.current()); methodCalls.add(1); } @Override public void onHalfClose() { assertSame(uniqueContext, Context.current()); methodCalls.add(2); } @Override public void onCancel() { assertSame(uniqueContext, Context.current()); methodCalls.add(3); } @Override public void onComplete() { assertSame(uniqueContext, Context.current()); methodCalls.add(4); } @Override public void onReady() { assertSame(uniqueContext, Context.current()); methodCalls.add(5); } }; ServerCall.Listener<Object> wrapped = interceptCall( uniqueContext, call, headers, new ServerCallHandler<Object, Object>() { @Override public ServerCall.Listener<Object> startCall( ServerCall<Object, Object> call, Metadata headers) { assertSame(ContextsTest.this.method, method); assertSame(ContextsTest.this.call, call); assertSame(ContextsTest.this.headers, headers); assertSame(uniqueContext, Context.current()); return listener; } }); assertSame(origContext, Context.current()); wrapped.onMessage(message); wrapped.onHalfClose(); wrapped.onCancel(); wrapped.onComplete(); wrapped.onReady(); assertEquals(Arrays.asList(1, 2, 3, 4, 5), methodCalls); assertSame(origContext, Context.current()); }
/** Tests for {@link Contexts}. */ @RunWith(JUnit4.class) public class ContextsTest { private static Context.Key<Object> contextKey = Context.key("key"); /** For use in comparing context by reference. */ private Context uniqueContext = Context.ROOT.withValue(contextKey, new Object()); @SuppressWarnings("unchecked") private MethodDescriptor<Object, Object> method = mock(MethodDescriptor.class); @SuppressWarnings("unchecked") private ServerCall<Object, Object> call = mock(ServerCall.class); private Metadata headers = new Metadata(); @Test public void interceptCall_basic() { Context origContext = Context.current(); final Object message = new Object(); final List<Integer> methodCalls = new ArrayList<Integer>(); final ServerCall.Listener<Object> listener = new ServerCall.Listener<Object>() { @Override public void onMessage(Object messageIn) { assertSame(message, messageIn); assertSame(uniqueContext, Context.current()); methodCalls.add(1); } @Override public void onHalfClose() { assertSame(uniqueContext, Context.current()); methodCalls.add(2); } @Override public void onCancel() { assertSame(uniqueContext, Context.current()); methodCalls.add(3); } @Override public void onComplete() { assertSame(uniqueContext, Context.current()); methodCalls.add(4); } @Override public void onReady() { assertSame(uniqueContext, Context.current()); methodCalls.add(5); } }; ServerCall.Listener<Object> wrapped = interceptCall( uniqueContext, call, headers, new ServerCallHandler<Object, Object>() { @Override public ServerCall.Listener<Object> startCall( ServerCall<Object, Object> call, Metadata headers) { assertSame(ContextsTest.this.method, method); assertSame(ContextsTest.this.call, call); assertSame(ContextsTest.this.headers, headers); assertSame(uniqueContext, Context.current()); return listener; } }); assertSame(origContext, Context.current()); wrapped.onMessage(message); wrapped.onHalfClose(); wrapped.onCancel(); wrapped.onComplete(); wrapped.onReady(); assertEquals(Arrays.asList(1, 2, 3, 4, 5), methodCalls); assertSame(origContext, Context.current()); } @Test public void interceptCall_restoresIfNextThrows() { Context origContext = Context.current(); try { interceptCall( uniqueContext, call, headers, new ServerCallHandler<Object, Object>() { @Override public ServerCall.Listener<Object> startCall( ServerCall<Object, Object> call, Metadata headers) { throw new RuntimeException(); } }); fail("Expected exception"); } catch (RuntimeException expected) { } assertSame(origContext, Context.current()); } @Test public void interceptCall_restoresIfListenerThrows() { Context origContext = Context.current(); final ServerCall.Listener<Object> listener = new ServerCall.Listener<Object>() { @Override public void onMessage(Object messageIn) { throw new RuntimeException(); } @Override public void onHalfClose() { throw new RuntimeException(); } @Override public void onCancel() { throw new RuntimeException(); } @Override public void onComplete() { throw new RuntimeException(); } @Override public void onReady() { throw new RuntimeException(); } }; ServerCall.Listener<Object> wrapped = interceptCall( uniqueContext, call, headers, new ServerCallHandler<Object, Object>() { @Override public ServerCall.Listener<Object> startCall( ServerCall<Object, Object> call, Metadata headers) { return listener; } }); try { wrapped.onMessage(new Object()); fail("Exception expected"); } catch (RuntimeException expected) { } try { wrapped.onHalfClose(); fail("Exception expected"); } catch (RuntimeException expected) { } try { wrapped.onCancel(); fail("Exception expected"); } catch (RuntimeException expected) { } try { wrapped.onComplete(); fail("Exception expected"); } catch (RuntimeException expected) { } try { wrapped.onReady(); fail("Exception expected"); } catch (RuntimeException expected) { } assertSame(origContext, Context.current()); } @Test public void statusFromCancelled_returnNullIfCtxNotCancelled() { Context context = Context.current(); assertFalse(context.isCancelled()); assertNull(statusFromCancelled(context)); } @Test public void statusFromCancelled_returnStatusAsSetOnCtx() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); cancellableContext.cancel(Status.DEADLINE_EXCEEDED.withDescription("foo bar").asException()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.DEADLINE_EXCEEDED, status.getCode()); assertEquals("foo bar", status.getDescription()); } @Test public void statusFromCancelled_shouldReturnStatusWithCauseAttached() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); Throwable t = new Throwable(); cancellableContext.cancel(t); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.CANCELLED, status.getCode()); assertSame(t, status.getCause()); } @Test public void statusFromCancelled_TimeoutExceptionShouldMapToDeadlineExceeded() { FakeClock fakeClock = new FakeClock(); Context.CancellableContext cancellableContext = Context.current() .withDeadlineAfter(100, TimeUnit.MILLISECONDS, fakeClock.scheduledExecutorService); fakeClock.forwardTime(System.nanoTime(), TimeUnit.NANOSECONDS); fakeClock.forwardMillis(100); assertTrue(cancellableContext.isCancelled()); assertThat(cancellableContext.cancellationCause(), instanceOf(TimeoutException.class)); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.DEADLINE_EXCEEDED, status.getCode()); assertEquals("context timed out", status.getDescription()); } @Test public void statusFromCancelled_returnCancelledIfCauseIsNull() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); cancellableContext.cancel(null); assertTrue(cancellableContext.isCancelled()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.CANCELLED, status.getCode()); } /** This is a whitebox test, to verify a special case of the implementation. */ @Test public void statusFromCancelled_StatusUnknownShouldWork() { Context.CancellableContext cancellableContext = Context.current().withCancellation(); Exception e = Status.UNKNOWN.asException(); cancellableContext.cancel(e); assertTrue(cancellableContext.isCancelled()); Status status = statusFromCancelled(cancellableContext); assertNotNull(status); assertEquals(Status.Code.UNKNOWN, status.getCode()); assertSame(e, status.getCause()); } @Test public void statusFromCancelled_shouldThrowIfCtxIsNull() { try { statusFromCancelled(null); fail("NPE expected"); } catch (NullPointerException npe) { assertEquals("context must not be null", npe.getMessage()); } } }