JavaScriptIsolate.java

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.javascriptengine;

import android.content.res.AssetFileDescriptor;
import android.os.Binder;
import android.os.RemoteException;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresFeature;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import androidx.javascriptengine.common.Utils;

import com.google.common.util.concurrent.ListenableFuture;

import org.chromium.android_webview.js_sandbox.common.IJsSandboxConsoleCallback;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolate;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateCallback;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateSyncCallback;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.annotation.concurrent.GuardedBy;

/**
 * Environment within a {@link JavaScriptSandbox} where Javascript is executed.
 * <p>
 * A single {@link JavaScriptSandbox} process can contain any number of {@link JavaScriptIsolate}
 * instances where JS can be evaluated independently and in parallel.
 * <p>
 * Each isolate has its own state and JS global object,
 * and cannot interact with any other isolate through JS APIs. There is only a <em>moderate</em>
 * security boundary between isolates in a single {@link JavaScriptSandbox}. If the code in one
 * {@link JavaScriptIsolate} is able to compromise the security of the JS engine then it may be
 * able to observe or manipulate other isolates, since they run in the same process. For strong
 * isolation multiple {@link JavaScriptSandbox} processes should be used, but it is not supported
 * at the moment. Please find the feature request <a href="https://crbug.com/1349860">here</a>.
 * <p>
 * Each isolate object must only be used from one thread.
 */
public final class JavaScriptIsolate implements AutoCloseable {
    private static final String TAG = "JavaScriptIsolate";
    private final Object mSetLock = new Object();
    /**
     * Interface to underlying service-backed implementation.
     * <p>
     * mJsIsolateStub should only be null when the Isolate has been explicitly closed - not when the
     * isolate has crashed or simply had its pending and future evaluations cancelled.
     */
    @Nullable
    private IJsSandboxIsolate mJsIsolateStub;
    private CloseGuardHelper mGuard = CloseGuardHelper.create();
    final JavaScriptSandbox mJsSandbox;

    @Nullable
    @GuardedBy("mSetLock")
    private HashSet<CallbackToFutureAdapter.Completer<String>> mPendingCompleterSet =
            new HashSet<CallbackToFutureAdapter.Completer<String>>();
    /**
     * If mSandboxClosed is true, new evaluations will throw this exception asynchronously.
     * <p>
     * Note that if the isolate is closed, IllegalStateException is thrown synchronously instead.
     */
    @Nullable
    private Exception mExceptionForNewEvaluations;
    private AtomicBoolean mSandboxClosed = new AtomicBoolean(false);
    IsolateStartupParameters mStartupParameters;

    private class IJsSandboxIsolateSyncCallbackStubWrapper extends
            IJsSandboxIsolateSyncCallback.Stub {
        private CallbackToFutureAdapter.Completer<String> mCompleter;

        IJsSandboxIsolateSyncCallbackStubWrapper(
                CallbackToFutureAdapter.Completer<String> completer) {
            mCompleter = completer;
        }

        @Override
        public void reportResultWithFd(AssetFileDescriptor afd) {
            mJsSandbox.mThreadPoolTaskExecutor.execute(
                    () -> {
                        String result;
                        try {
                            result = Utils.readToString(afd,
                                    mStartupParameters.getMaxEvaluationReturnSizeBytes(), false);
                        } catch (IOException | UnsupportedOperationException ex) {
                            mCompleter.setException(
                                    new JavaScriptException(
                                            "Retrieving result failed: " + ex.getMessage()));
                            removePending(mCompleter);
                            return;
                        } catch (IllegalArgumentException ex) {
                            if (ex.getMessage() != null) {
                                mCompleter.setException(
                                        new EvaluationResultSizeLimitExceededException(
                                                ex.getMessage()));
                            } else {
                                mCompleter.setException(
                                        new EvaluationResultSizeLimitExceededException());
                            }
                            removePending(mCompleter);
                            return;
                        }
                        handleEvaluationResult(mCompleter, result);
                    });
        }

        @Override
        public void reportErrorWithFd(@ExecutionErrorTypes int type, AssetFileDescriptor afd) {
            mJsSandbox.mThreadPoolTaskExecutor.execute(
                    () -> {
                        String error;
                        try {
                            error = Utils.readToString(afd,
                                    mStartupParameters.getMaxEvaluationReturnSizeBytes(), true);
                        } catch (IOException | UnsupportedOperationException ex) {
                            mCompleter.setException(
                                    new JavaScriptException(
                                            "Retrieving error failed: " + ex.getMessage()));
                            removePending(mCompleter);
                            return;
                        }
                        handleEvaluationError(mCompleter, type, error);
                    });
        }
    }

    private class IJsSandboxIsolateCallbackStubWrapper extends IJsSandboxIsolateCallback.Stub {
        private CallbackToFutureAdapter.Completer<String> mCompleter;

        IJsSandboxIsolateCallbackStubWrapper(CallbackToFutureAdapter.Completer<String> completer) {
            mCompleter = completer;
        }

        @Override
        public void reportResult(String result) {
            handleEvaluationResult(mCompleter, result);
        }

        @Override
        public void reportError(@ExecutionErrorTypes int type, String error) {
            handleEvaluationError(mCompleter, type, error);
        }
    }

    private static final class JsSandboxConsoleCallbackRelay
            extends IJsSandboxConsoleCallback.Stub {
        private final Executor mExecutor;
        private final JavaScriptConsoleCallback mCallback;

        JsSandboxConsoleCallbackRelay(Executor executor, JavaScriptConsoleCallback callback) {
            mExecutor = executor;
            mCallback = callback;
        }

        @Override
        public void consoleMessage(final int contextGroupId, final int level, final String message,
                final String source, final int line, final int column, final String trace) {
            final long identity = Binder.clearCallingIdentity();
            try {
                mExecutor.execute(() -> {
                    if ((level & JavaScriptConsoleCallback.ConsoleMessage.LEVEL_ALL) == 0
                            || ((level - 1) & level) != 0) {
                        throw new IllegalArgumentException(
                                "invalid console level " + level + " provided by isolate");
                    }
                    if (message == null) {
                        throw new IllegalArgumentException("null message provided by isolate");
                    }
                    if (source == null) {
                        throw new IllegalArgumentException("null source provided by isolate");
                    }
                    mCallback.onConsoleMessage(
                            new JavaScriptConsoleCallback.ConsoleMessage(
                                level, message, source, line, column, trace));
                });
            } catch (RejectedExecutionException e) {
                Log.e(TAG, "Console message dropped", e);
            } finally {
                Binder.restoreCallingIdentity(identity);
            }
        }

        @Override
        public void consoleClear(int contextGroupId) {
            final long identity = Binder.clearCallingIdentity();
            try {
                mExecutor.execute(() -> mCallback.onConsoleClear());
            } catch (RejectedExecutionException e) {
                Log.e(TAG, "Console clear dropped", e);
            } finally {
                Binder.restoreCallingIdentity(identity);
            }
        }
    }

    JavaScriptIsolate(IJsSandboxIsolate jsIsolateStub, JavaScriptSandbox sandbox,
            IsolateStartupParameters settings) {
        mJsSandbox = sandbox;
        mJsIsolateStub = jsIsolateStub;
        mStartupParameters = settings;
        mGuard.open("close");
        // This should be at the end of the constructor.
    }

    /**
     * Evaluates the given JavaScript code and returns the result.
     * <p>
     * There are 3 possible behaviors based on the output of the expression:
     * <ul>
     *   <li><strong>If the JS expression returns a JS String</strong>, then the Java Future
     * resolves to Java String.</li>
     *   <li><strong>If the JS expression returns a JS Promise</strong>,
     * and if {@link JavaScriptSandbox#isFeatureSupported(String)} for
     * {@link JavaScriptSandbox#JS_FEATURE_PROMISE_RETURN} returns {@code true}, Java Future
     * resolves to Java String once the promise resolves. If it returns {@code false}, then the
     * Future resolves to an empty string.</li>
     *   <li><strong>If the JS expression returns another data type</strong>, then Java Future
     * resolves to empty Java String.</li>
     * </ul>
     * The environment uses a single JS global object for all the calls to {@link
     * #evaluateJavaScriptAsync(String)} and {@link #provideNamedData(String, byte[])} methods.
     * These calls are queued up and are run one at a time in sequence, using the single JS
     * environment for the isolate. The global variables set by one evaluation are visible for
     * later evaluations. This is similar to adding multiple {@code <script>} tags in HTML. The
     * behavior is also similar to
     * {@link android.webkit.WebView#evaluateJavascript(String, android.webkit.ValueCallback)}.
     * <p>
     * If {@link JavaScriptSandbox#isFeatureSupported(String)} for
     * {@link JavaScriptSandbox#JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT} returns {@code
     * false},
     * the size of the expression to be evaluated and the return/error value is limited by the
     * binder transaction limit ({@link android.os.TransactionTooLargeException}). If it returns
     * {@code true}, they are not limited by the binder
     * transaction limit but are bound by
     * {@link IsolateStartupParameters#setMaxEvaluationReturnSizeBytes(int)} with a default size
     * of {@link IsolateStartupParameters#DEFAULT_MAX_EVALUATION_RETURN_SIZE_BYTES}.
     *
     * @param code JavaScript code that is evaluated, it should return a JavaScript String or a
     *             Promise of a String in case {@link JavaScriptSandbox#JS_FEATURE_PROMISE_RETURN}
     *             is supported
     * @return Future that evaluates to the result String of the evaluation or exceptions (see
     * {@link JavaScriptException} and subclasses) if there is an error
     */
    @SuppressWarnings("NullAway")
    @NonNull
    public ListenableFuture<String> evaluateJavaScriptAsync(@NonNull String code) {
        if (!mSandboxClosed.get() && mJsSandbox.isFeatureSupported(
                JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT)) {
            // This process can be made more memory efficient by converting the String to
            // UTF-8 encoded bytes and writing to the pipe in chunks.
            byte[] inputBytes = code.getBytes(StandardCharsets.UTF_8);
            return evaluateJavaScriptAsync(inputBytes);
        }
        if (mJsIsolateStub == null) {
            throw new IllegalStateException(
                    "Calling evaluateJavaScriptAsync() after closing the Isolate");
        }
        return CallbackToFutureAdapter.getFuture(completer -> {
            final String futureDebugMessage = "evaluateJavascript Future";
            IJsSandboxIsolateCallbackStubWrapper callbackStub;
            synchronized (mSetLock) {
                if (mPendingCompleterSet == null) {
                    completer.setException(mExceptionForNewEvaluations);
                    return futureDebugMessage;
                }
                mPendingCompleterSet.add(completer);
            }
            callbackStub = new IJsSandboxIsolateCallbackStubWrapper(completer);
            try {
                mJsIsolateStub.evaluateJavascript(code, callbackStub);
            } catch (RemoteException e) {
                completer.setException(new RuntimeException(e));
                synchronized (mSetLock) {
                    mPendingCompleterSet.remove(completer);
                }
            }
            // Debug string.
            return futureDebugMessage;
        });
    }

    /**
     * Evaluates the given JavaScript code which is encoded in UTF-8 and returns the result.
     * <p>
     * Please refer to the documentation of {@link #evaluateJavaScriptAsync(String)} as the
     * behavior of this method is similar other than for the input type.
     * <p>
     * <strong>Note: The {@code byte[]} must be UTF-8 encoded.</strong>
     * <p>
     * This overload is provided for clients to pass in a UTF-8 encoded {@code byte[]} directly
     * instead of having to convert it into a {@code String} to use
     * {@link #evaluateJavaScriptAsync(String)}.
     *
     * @param code UTF-8 encoded JavaScript code that is evaluated, it should return a JavaScript
     *             String or a Promise of a String in case
     *             {@link JavaScriptSandbox#JS_FEATURE_PROMISE_RETURN} is supported
     * @return Future that evaluates to the result String of the evaluation or exceptions (see
     * {@link JavaScriptException} and subclasses) if there is an error
     */
    @SuppressWarnings("NullAway")
    @NonNull
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public ListenableFuture<String> evaluateJavaScriptAsync(@NonNull byte[] code) {
        if (mJsIsolateStub == null) {
            throw new IllegalStateException(
                    "Calling evaluateJavaScriptAsync() after closing the Isolate");
        }
        return CallbackToFutureAdapter.getFuture(completer -> {
            final String futureDebugMessage = "evaluateJavascript Future";
            IJsSandboxIsolateSyncCallbackStubWrapper callbackStub;
            synchronized (mSetLock) {
                if (mPendingCompleterSet == null) {
                    completer.setException(mExceptionForNewEvaluations);
                    return futureDebugMessage;
                }
                mPendingCompleterSet.add(completer);
            }
            callbackStub = new IJsSandboxIsolateSyncCallbackStubWrapper(completer);
            try {
                AssetFileDescriptor codeAfd = Utils.writeBytesIntoPipeAsync(code,
                        mJsSandbox.mThreadPoolTaskExecutor);
                try {
                    mJsIsolateStub.evaluateJavascriptWithFd(codeAfd, callbackStub);
                } finally {
                    // We pass the codeAfd to the separate sandbox process but we still need to
                    // close it on our end to avoid file descriptor leaks.
                    codeAfd.close();
                }
            } catch (RemoteException | IOException e) {
                completer.setException(new RuntimeException(e));
                synchronized (mSetLock) {
                    mPendingCompleterSet.remove(completer);
                }
            }
            // Debug string.
            return futureDebugMessage;
        });
    }

    /**
     * Closes the {@link JavaScriptIsolate} object and renders it unusable.
     * <p>
     * Once closed, no more method calls should be made. Pending evaluations resolve with
     * {@link IsolateTerminatedException} immediately.
     * <p>
     * If {@link JavaScriptSandbox#isFeatureSupported(String)} is {@code true} for {@link
     * JavaScriptSandbox#JS_FEATURE_ISOLATE_TERMINATION}, then any pending evaluation is immediately
     * terminated and memory is freed. If it is {@code false}, the isolate will not get cleaned
     * up until the pending evaluations have run to completion and will consume resources until
     * then.
     */
    @Override
    public void close() {
        // IllegalStateException will be thrown synchronously instead for new evaluations.
        mExceptionForNewEvaluations = null;
        if (mJsIsolateStub == null) {
            return;
        }
        try {
            cancelAllPendingEvaluations(new IsolateTerminatedException());
            mJsIsolateStub.close();
        } catch (RemoteException e) {
            Log.e(TAG, "RemoteException was thrown during close()", e);
        }
        mJsIsolateStub = null;
        mJsSandbox.removeFromIsolateSet(this);
        mGuard.close();
    }

    /**
     * Provides a byte array for consumption from the JavaScript environment.
     * <p>
     * This method provides an efficient way to pass in data from Java into the JavaScript
     * environment which can be referred to from JavaScript. This is more efficient than including
     * data in the JS expression, and allows large data to be sent.
     * <p>
     * This data can be consumed in the JS environment using {@code
     * android.consumeNamedDataAsArrayBuffer(String)} by referring to the data with the name that
     * was used when calling this method. This is a one-time transfer and the calls should be
     * paired.
     * <p>
     * A single name can only be used once in a particular {@link JavaScriptIsolate}.
     * Clients can generate unique names for each call if they
     * need to use this method multiple times. The same name should be included into the JS code.
     * <p>
     * This API can be used to pass a WASM module into the JS
     * environment for compilation if {@link JavaScriptSandbox#isFeatureSupported(String)} returns
     * {@code true} for {@link JavaScriptSandbox#JS_FEATURE_WASM_COMPILATION}.
     * <br>
     * In Java,
     * <pre>
     *     jsIsolate.provideNamedData("id-1", byteArray);
     * </pre>
     * In JS,
     * <pre>
     *     android.consumeNamedDataAsArrayBuffer("id-1").then((value) => {
     *       return WebAssembly.compile(value).then((module) => {
     *          ...
     *       });
     *     });
     * </pre>
     * <p>
     * The environment uses a single JS global object for all the calls to {@link
     * #evaluateJavaScriptAsync(String)} and {@link #provideNamedData(String, byte[])} methods.
     * <p>
     * This method should only be called if
     * {@link JavaScriptSandbox#isFeatureSupported(String)}
     * returns true for {@link JavaScriptSandbox#JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER}.
     *
     * @param name       Identifier for the data that is passed, the same identifier should be used
     *                   in the JavaScript environment to refer to the data
     * @param inputBytes Bytes to be passed into the JavaScript environment. This array must not be
     *                   modified until the JavaScript promise returned by
     *                   consumeNamedDataAsArrayBuffer has resolved (or rejected).
     * @return {@code true} on success, {@code false} if the name has already been used before,
     * in which case the client should use an unused name
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public boolean provideNamedData(@NonNull String name, @NonNull byte[] inputBytes) {
        if (mJsIsolateStub == null) {
            throw new IllegalStateException("Calling provideNamedData() after closing the Isolate");
        }
        if (name == null) {
            throw new NullPointerException("name parameter cannot be null");
        }
        try {
            AssetFileDescriptor codeAfd = Utils.writeBytesIntoPipeAsync(inputBytes,
                    mJsSandbox.mThreadPoolTaskExecutor);
            try {
                return mJsIsolateStub.provideNamedData(name, codeAfd);
            } finally {
                // We pass the codeAfd to the separate sandbox process but we still need to close
                // it on our end to avoid file descriptor leaks.
                codeAfd.close();
            }
        } catch (RemoteException e) {
            Log.e(TAG, "RemoteException was thrown during provideNamedData()", e);
        } catch (IOException e) {
            Log.e(TAG, "IOException was thrown during provideNamedData", e);
        }
        return false;
    }

    void handleEvaluationError(CallbackToFutureAdapter.Completer<String> completer,
            int type, String error) {
        boolean crashing = false;
        switch (type) {
            case IJsSandboxIsolateSyncCallback.JS_EVALUATION_ERROR:
                completer.setException(new EvaluationFailedException(error));
                break;
            case IJsSandboxIsolateSyncCallback.MEMORY_LIMIT_EXCEEDED:
                completer.setException(new MemoryLimitExceededException(error));
                crashing = true;
                break;
            default:
                completer.setException(new JavaScriptException(
                        "Crashing due to unknown JavaScriptException: " + error));
                // Assume the worst
                crashing = true;
        }
        removePending(completer);
        if (crashing) {
            handleCrash();
        }
    }

    void handleEvaluationResult(CallbackToFutureAdapter.Completer<String> completer,
            String result) {
        completer.set(result);
        removePending(completer);
    }

    void notifySandboxClosed() {
        mSandboxClosed.set(true);
        cancelAllPendingEvaluations(new SandboxDeadException());
    }

    // Cancel all pending and future evaluations with the given exception.
    // Only the first call to this method has any effect.
    void cancelAllPendingEvaluations(Exception e) {
        final HashSet<CallbackToFutureAdapter.Completer<String>> pendingSet;
        synchronized (mSetLock) {
            if (mPendingCompleterSet == null) return;
            pendingSet = mPendingCompleterSet;
            mPendingCompleterSet = null;
            mExceptionForNewEvaluations = e;
        }
        for (CallbackToFutureAdapter.Completer<String> ele : pendingSet) {
            ele.setException(e);
        }
    }

    void removePending(CallbackToFutureAdapter.Completer<String> completer) {
        synchronized (mSetLock) {
            if (mPendingCompleterSet != null) {
                mPendingCompleterSet.remove(completer);
            }
        }
    }

    void handleCrash() {
        cancelAllPendingEvaluations(new IsolateTerminatedException());
    }

    @Override
    @SuppressWarnings("GenericException") // super.finalize() throws Throwable
    protected void finalize() throws Throwable {
        try {
            if (mGuard != null) {
                mGuard.warnIfOpen();
            }
            if (mJsIsolateStub != null) {
                close();
            }
        } finally {
            super.finalize();
        }
    }

    /**
     * Set a JavaScriptConsoleCallback to process console messages from the isolate.
     * <p>
     * Scripts always have access to console APIs, regardless of whether a console callback is
     * set. By default, no console callback is set and calling a console API from JavaScript will do
     * nothing, and will be relatively cheap. Setting a console callback allows console messages to
     * be forwarded to the embedding application, but may negatively impact performance.
     * <p>
     * Note that console APIs may expose messages generated by both JavaScript code and
     * V8/JavaScriptEngine internals.
     * <p>
     * Use caution if using this in production code as it may result in the exposure of debugging
     * information or secrets through logs.
     * <p>
     * When setting a console callback, this method should be called before requesting any
     * evaluations. Calling setConsoleCallback after requesting evaluations may result in those
     * pending evaluations' console messages being dropped or logged to a previous console callback.
     * <p>
     * Note that delayed console messages may continue to be delivered after the isolate has been
     * closed (or has crashed).
     * @param executor Executor for running callback methods.
     * @param callback Callback implementing console logging behaviour.
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void setConsoleCallback(@NonNull Executor executor,
            @NonNull JavaScriptConsoleCallback callback) {
        if (executor == null) {
            throw new IllegalArgumentException("executor cannot be null");
        }
        if (callback == null) {
            throw new IllegalArgumentException("callback cannot be null");
        }
        if (mJsIsolateStub == null) {
            throw new IllegalStateException(
                    "Calling setConsoleCallback() after closing the Isolate");
        }
        try {
            mJsIsolateStub.setConsoleCallback(
                    new JsSandboxConsoleCallbackRelay(executor, callback));
        } catch (RemoteException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Set a JavaScriptConsoleCallback to process console messages from the isolate.
     * <p>
     * This is the same as calling {@link #setConsoleCallback(Executor, JavaScriptConsoleCallback}
     * using the main executor ({@link Context.getMainExecutor()}) of the context used to create the
     * {@link JavaScriptSandbox} object.
     * @param callback Callback implementing console logging behaviour.
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void setConsoleCallback(@NonNull JavaScriptConsoleCallback callback) {
        setConsoleCallback(mJsSandbox.getMainExecutor(), callback);
    }

    /**
     * Clear any JavaScriptConsoleCallback set via {@link #setConsoleCallback}.
     * <p>
     * Clearing a callback is not guaranteed to take effect for any already pending evaluations.
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void clearConsoleCallback() {
        if (mJsIsolateStub == null) {
            throw new IllegalStateException(
                    "Calling clearConsoleCallback() after closing the Isolate");
        }
        try {
            mJsIsolateStub.setConsoleCallback(null);
        } catch (RemoteException e) {
            throw new RuntimeException(e);
        }
    }
}