wallet full: cancel native calls before closing
What changed, and why it matters
This commit hardens how the Java Monero wallet shuts down. It makes sure background native (C++) calls are cancelled and finish before the wallet is freed, and it protects listener cleanup from running at the same time as notifications. The main risk being fixed is crashes, memory corruption, or use-after-free bugs that can happen if the wallet is closed while native code or Java callbacks are still active.
Treat this as a stability and potential security hardening fix. Upgrade to the commit that includes these changes, run integration tests that exercise concurrent wallet close/sync/listener scenarios, and verify the monero-cpp submodule bump does not introduce unrelated behavioral changes.
Security signals we found
Use-after-free / double-free risk in close path
Race condition between native listener callbacks and wallet destruction
Cross-thread JNIEnv handling in JNI listener destructor
Concurrent modification / listener invocation during shutdown
Native I/O cancellation before resource release
Evidence from the diff
The patch reworks close() in MoneroWalletFull to: (1) set isClosed early to reject new calls, (2) call a new requestShutdownJni() to cancel in-flight native I/O, (3) wait for active calls under a write lock, (4) then closeJni() and clear handles. It also changes the JNI bridge so the C++ listener is created once and kept until close drains callbacks, fixes the listener destructor to attach the correct JNIEnv for the current thread, and makes connection-manager listener iteration snapshot-based. These changes address race conditions between shutdown and native callbacks/callers.
Changed components
src/main/java/monero/wallet/MoneroWalletFull.javasrc/main/java/monero/wallet/MoneroWalletDefault.javasrc/main/java/monero/common/MoneroConnectionManager.javasrc/main/cpp/monero_jni_bridge.cppsrc/main/cpp/monero_jni_bridge.hexternal/monero-cpp (submodule)Inspect captured patch +123 / −57
### external/monero-cpp
@@ -1 +1 @@
-Subproject commit 6927f7bf52dabad2179dfe2a0a0e1a95e12290c1
+Subproject commit db05b56367d0d69cb6ee83973d02da762b2c3cf0
### src/main/cpp/monero_jni_bridge.cpp
@@ -224,19 +224,20 @@ void detachJVM(JNIEnv *env, int envStat) {
struct wallet_jni_listener : public monero_wallet_listener {
jobject jlistener;
- JNIEnv* m_env;
std::mutex _listenerMutex;
- // TODO: use this env instead of attaching each time? performance improvement?
wallet_jni_listener(JNIEnv* env, jobject listener) {
jlistener = env->NewGlobalRef(listener);
- m_env = env;
}
~wallet_jni_listener() {
std::lock_guard<std::mutex> lock(_listenerMutex);
- m_env->DeleteGlobalRef(jlistener);
+ JNIEnv* env;
+ int envStat = attachJVM(&env); // a listener can be removed on a different thread than it was created
+ if (envStat == JNI_ERR) return;
+ env->DeleteGlobalRef(jlistener);
jlistener = nullptr;
+ detachJVM(env, envStat);
};
void on_sync_progress(uint64_t height, uint64_t start_height, uint64_t end_height, double percent_done, const string& message) override {
@@ -747,23 +748,17 @@ JNIEXPORT jstring JNICALL Java_monero_wallet_MoneroWalletFull_getAddressIndexJni
}
/**
- * Only one listener needs to subscribe over JNI, so this removes the previously registered listener
- * and registers the new listener.
+ * Registers one JNI listener for the wallet's lifetime, until close drains native callbacks.
*/
JNIEXPORT jlong JNICALL Java_monero_wallet_MoneroWalletFull_setListenerJni(JNIEnv *env, jobject instance, jobject jlistener) {
MTRACE("Java_monero_wallet_MoneroWalletFull_setListenerJni");
monero_wallet* wallet = get_handle<monero_wallet>(env, instance, JNI_WALLET_HANDLE);
- // remove old listener
- wallet_jni_listener* old_listener = get_handle<wallet_jni_listener>(env, instance, JNI_LISTENER_HANDLE);
- if (old_listener != nullptr) {
- wallet->remove_listener(*old_listener);
- delete old_listener;
- }
-
- // add new listener
+ // retain the listener so removal from a Java callback cannot delete its active JNI bridge
+ wallet_jni_listener* listener = get_handle<wallet_jni_listener>(env, instance, JNI_LISTENER_HANDLE);
+ if (listener != nullptr) return reinterpret_cast<jlong>(listener);
if (jlistener == nullptr) return 0;
- wallet_jni_listener* listener = new wallet_jni_listener(env, jlistener);
+ listener = new wallet_jni_listener(env, jlistener);
wallet->add_listener(*listener);
return reinterpret_cast<jlong>(listener);
}
@@ -2212,12 +2207,27 @@ JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_saveJni(JNIEnv* env,
}
}
+JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_requestShutdownJni(JNIEnv* env, jobject instance) {
+ monero_wallet_full* wallet = get_handle<monero_wallet_full>(env, instance, JNI_WALLET_HANDLE);
+ try {
+ wallet->request_shutdown();
+ } catch (...) {
+ rethrow_cpp_exception_as_java_exception(env);
+ }
+}
+
JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_closeJni(JNIEnv* env, jobject instance, jboolean save) {
MTRACE("Java_monero_wallet_MoneroWalletFull_CloseJni");
monero_wallet* wallet = get_handle<monero_wallet>(env, instance, JNI_WALLET_HANDLE);
- if (save) wallet->save();
- delete wallet;
- wallet = nullptr;
+ try {
+ wallet->close(save); // cancel and drain native work before saving or releasing the JNI listener
+ delete wallet;
+ env->SetLongField(instance, get_handle_field(env, instance, JNI_WALLET_HANDLE), 0);
+ delete get_handle<wallet_jni_listener>(env, instance, JNI_LISTENER_HANDLE);
+ env->SetLongField(instance, get_handle_field(env, instance, JNI_LISTENER_HANDLE), 0);
+ } catch (...) {
+ rethrow_cpp_exception_as_java_exception(env);
+ }
}
JNIEXPORT jbyteArray JNICALL Java_monero_wallet_MoneroWalletFull_getKeysFileBufferJni(JNIEnv* env, jobject instance, jstring jpassword, jboolean view_only) {
### src/main/cpp/monero_jni_bridge.h
@@ -269,6 +269,8 @@ JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_moveToJni(JNIEnv *, j
JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_saveJni(JNIEnv *, jobject);
+JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_requestShutdownJni(JNIEnv *, jobject);
+
JNIEXPORT void JNICALL Java_monero_wallet_MoneroWalletFull_closeJni(JNIEnv *, jobject, jboolean);
JNIEXPORT jbyteArray JNICALL Java_monero_wallet_MoneroWalletFull_getKeysFileBufferJni(JNIEnv *, jobject, jstring, jboolean);
### src/main/java/monero/common/MoneroConnectionManager.java
@@ -511,9 +511,11 @@ public MoneroConnectionManager reset() {
// ----------------------------- PRIVATE HELPERS ----------------------------
private void onConnectionChanged(MoneroRpcConnection connection) {
+ List<MoneroConnectionManagerListener> listenersSnapshot;
synchronized (listeners) {
- for (MoneroConnectionManagerListener listener : listeners) listener.onConnectionChanged(connection);
+ listenersSnapshot = new ArrayList<MoneroConnectionManagerListener>(listeners);
}
+ for (MoneroConnectionManagerListener listener : listenersSnapshot) listener.onConnectionChanged(connection);
}
private List<List<MoneroRpcConnection>> getConnectionsInAscendingPriority() {
### src/main/java/monero/wallet/MoneroWalletDefault.java
@@ -154,19 +154,31 @@ public void setDaemonConnection(String uri, String username, String password) {
@Override
public void setConnectionManager(MoneroConnectionManager connectionManager) {
- if (this.connectionManager != null) this.connectionManager.removeListener(connectionManagerListener);
+ removeConnectionManagerListener(this.connectionManager);
this.connectionManager = connectionManager;
if (connectionManager == null) return;
if (connectionManagerListener == null) connectionManagerListener = new MoneroConnectionManagerListener() {
@Override
public void onConnectionChanged(MoneroRpcConnection connection) {
- setDaemonConnection(connection);
+ if (isClosed) return;
+ try {
+ setDaemonConnection(connection);
+ } catch (MoneroError e) {
+ if (!isClosed) throw e; // ignore a connection change racing with wallet shutdown
+ }
}
};
connectionManager.addListener(connectionManagerListener);
setDaemonConnection(connectionManager.getConnection());
}
+ protected void removeConnectionManagerListener(MoneroConnectionManager manager) {
+ if (manager == null) return;
+ synchronized (manager.getListeners()) {
+ if (manager.getListeners().contains(connectionManagerListener)) manager.removeListener(connectionManagerListener);
+ }
+ }
+
@Override
public MoneroConnectionManager getConnectionManager() {
return connectionManager;
### src/main/java/monero/wallet/MoneroWalletFull.java
@@ -34,9 +34,11 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Logger;
+import monero.common.MoneroConnectionManager;
import monero.common.MoneroError;
import monero.common.MoneroRpcConnection;
import monero.common.MoneroUtils;
@@ -88,14 +90,15 @@ public class MoneroWalletFull extends MoneroWalletDefault {
// class variables
private static final Logger LOGGER = Logger.getLogger(MoneroWalletFull.class.getName());
private static final long DEFAULT_SYNC_PERIOD_IN_MS = 10000; // default period betweeen syncs in ms
- private static final long CLOSE_WAIT_MS = 30000; // maximum time to wait for other calls to finish before closing
+ private static final long CLOSE_WAIT_MS = 45000; // maximum time to wait for other calls to finish before closing
// instance variables
private long jniWalletHandle; // memory address of the wallet in c++; this variable is read directly by name in c++
private long jniListenerHandle; // memory address of the wallet listener in c++; this variable is read directly by name in c++
private WalletJniListener jniListener; // receives notifications from jni c++
private String password;
private final ReentrantReadWriteLock callLock = new ReentrantReadWriteLock(); // calls hold the read lock so close() cannot free the wallet while they execute
+ private final Object closeLock = new Object(); // serialize close attempts independently of wallet operations
/**
* Private constructor with a handle to the memory address of the wallet in c++.
@@ -106,6 +109,7 @@ public class MoneroWalletFull extends MoneroWalletDefault {
private MoneroWalletFull(long jniWalletHandle, String password) {
this.jniWalletHandle = jniWalletHandle;
this.jniListener = new WalletJniListener();
+ this.listeners = new CopyOnWriteArraySet<MoneroWalletListenerI>();
this.password = password;
}
@@ -472,7 +476,7 @@ public void addListener(MoneroWalletListenerI listener) {
beginCall();
try {
super.addListener(listener);
- refreshListening();
+ initListening();
} finally {
endCall();
}
@@ -483,7 +487,6 @@ public void removeListener(MoneroWalletListenerI listener) {
beginCall();
try {
super.removeListener(listener);
- refreshListening();
} finally {
endCall();
}
@@ -505,6 +508,23 @@ public boolean isViewOnly() {
}
}
+ @Override
+ public void setConnectionManager(MoneroConnectionManager connectionManager) {
+ beginCall();
+ try {
+ super.setConnectionManager(connectionManager);
+ } finally {
+ try {
+ if (isClosed) { // undo a registration that raced with close detaching the manager
+ removeConnectionManagerListener(connectionManager);
+ this.connectionManager = null;
+ }
+ } finally {
+ endCall();
+ }
+ }
+ }
+
@Override
public void setDaemonConnection(MoneroRpcConnection daemonConnection) {
setDaemonConnection(daemonConnection, null);
@@ -751,7 +771,7 @@ public MoneroSyncResult sync(Long startHeight, MoneroWalletListenerI listener) {
} catch (Exception e) {
throw new MoneroError(e.getMessage());
} finally {
- if (listener != null) removeListener(listener); // unregister listener
+ if (listener != null) listeners.remove(listener); // cleanup can race with close or removal from the callback
}
} finally {
endCall();
@@ -1802,27 +1822,32 @@ public void save() {
}
@Override
- public synchronized void close(boolean save) {
- if (isClosed) return; // closing a closed wallet has no effect
- super.close(save); // marks the wallet closed so new calls are rejected
- password = null;
- refreshListening();
- try { stopSyncingJni(); } catch (Exception e) { } // abort sync so in-flight calls finish promptly
-
- // wait for in-flight calls to finish before freeing the wallet in c++
- boolean locked = false;
- try {
- locked = callLock.writeLock().tryLock(CLOSE_WAIT_MS, TimeUnit.MILLISECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ public void close(boolean save) {
+ if (callLock.getReadHoldCount() > 0 || Boolean.TRUE.equals(jniListener.notifying.get())) {
+ throw new MoneroError("Cannot close wallet from an active wallet call or listener callback");
}
- if (!locked) LOGGER.warning("Closing wallet after timeout waiting for other calls to finish");
- try {
- closeJni(save);
- } catch (Exception e) {
- throw new MoneroError(e.getMessage());
- } finally {
- if (locked) callLock.writeLock().unlock();
+ synchronized (closeLock) {
+ if (jniWalletHandle == 0) return; // a failed close can be retried while its native handle remains
+ isClosed = true; // reject new calls before cancelling native I/O
+ boolean locked = false;
+ try {
+ requestShutdownJni();
+ removeConnectionManagerListener(connectionManager); // detach after cancellation so connection callbacks can finish
+ locked = callLock.writeLock().tryLock(CLOSE_WAIT_MS, TimeUnit.MILLISECONDS);
+ if (!locked) throw new MoneroError("Timed out waiting for active wallet calls; close can be retried");
+ removeConnectionManagerListener(connectionManager); // recheck after acquiring visibility of completed registrations
+ connectionManager = null;
+ closeJni(save);
+ password = null;
+ super.close(save); // clear Java listeners after native callbacks have finished
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new MoneroError("Interrupted waiting for active wallet calls; close can be retried");
+ } catch (Exception e) {
+ throw new MoneroError(e.getMessage());
+ } finally {
+ if (locked) callLock.writeLock().unlock();
+ }
}
}
@@ -2039,6 +2064,8 @@ public synchronized void close(boolean save) {
private native void moveToJni(String path, String password);
private native void saveJni();
+
+ private native void requestShutdownJni();
private native void closeJni(boolean save);
@@ -2049,17 +2076,29 @@ public synchronized void close(boolean save) {
*/
@SuppressWarnings("unused") // called directly from jni c++
private class WalletJniListener {
+
+ private final ThreadLocal<Boolean> notifying = new ThreadLocal<Boolean>();
+
+ private void notifyListeners(Runnable notification) {
+ if (isClosed) return;
+ notifying.set(true);
+ try {
+ notification.run();
+ } finally {
+ notifying.remove();
+ }
+ }
public void onSyncProgress(long height, long startHeight, long endHeight, double percentDone, String message) {
- announceSyncProgress(height, startHeight, endHeight, percentDone, message);
+ notifyListeners(() -> announceSyncProgress(height, startHeight, endHeight, percentDone, message));
}
public void onNewBlock(long height) {
- announceNewBlock(height);
+ notifyListeners(() -> announceNewBlock(height));
}
public void onBalancesChanged(String newBalanceStr, String newUnlockedBalanceStr) {
- announceBalancesChanged(new BigInteger(newBalanceStr), new BigInteger(newUnlockedBalanceStr));
+ notifyListeners(() -> announceBalancesChanged(new BigInteger(newBalanceStr), new BigInteger(newUnlockedBalanceStr)));
}
public void onOutputReceived(long height, String txHash, String amountStr, int accountIdx, int subaddressIdx, int version, String unlockTimeStr, boolean isLocked) {
@@ -2090,7 +2129,7 @@ public void onOutputReceived(long height, String txHash, String amountStr, int a
}
// announce output
- announceOutputReceived((MoneroOutputWallet) tx.getOutputs().get(0));
+ notifyListeners(() -> announceOutputReceived((MoneroOutputWallet) tx.getOutputs().get(0)));
}
public void onOutputSpent(long height, String txHash, String amountStr, String accountIdxStr, String subaddressIdxStr, int version, String unlockTimeStr, boolean isLocked) {
@@ -2121,7 +2160,7 @@ public void onOutputSpent(long height, String txHash, String amountStr, String a
}
// announce output
- announceOutputSpent((MoneroOutputWallet) tx.getInputs().get(0));
+ notifyListeners(() -> announceOutputSpent((MoneroOutputWallet) tx.getInputs().get(0)));
}
}
@@ -2281,12 +2320,12 @@ private static class AddressBookEntriesContainer {
// ---------------------------- PRIVATE HELPERS -----------------------------
/**
- * Enables or disables listening in the c++ wallet.
+ * Initializes the c++ listener once and retains it until close drains native callbacks.
*/
- private void refreshListening() {
- boolean isEnabled = listeners.size() > 0;
- if (jniListenerHandle == 0 && !isEnabled || jniListenerHandle > 0 && isEnabled) return; // no difference
- jniListenerHandle = setListenerJni(isEnabled ? jniListener : null);
+ private void initListening() {
+ synchronized (jniListener) {
+ if (jniListenerHandle == 0) jniListenerHandle = setListenerJni(jniListener);
+ }
}
private void assertNotClosed() {
@@ -2295,7 +2334,8 @@ private void assertNotClosed() {
// acquire the shared call lock so close() waits for this call before freeing the wallet
private void beginCall() {
- callLock.readLock().lock();
+ assertNotClosed();
+ if (!callLock.readLock().tryLock()) throw new MoneroError("Wallet is closed");
if (isClosed) {
callLock.readLock().unlock();
throw new MoneroError("Wallet is closed");Why this scored 43/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.