From 763e803e2c3070d99a217a1912e6fac363614148 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:47:02 +0530 Subject: [PATCH 1/2] fix(sftp): cancel stalled connections and prevent delayed errors --- src/dialogs/loader.js | 34 ++- src/fileSystem/sftp.js | 17 ++ src/lib/remoteStorage.js | 16 +- src/pages/fileBrowser/fileBrowser.js | 3 +- src/plugins/sftp/index.d.ts | 4 + .../sftp/src/com/foxdebug/sftp/Sftp.java | 241 +++++++++++++++--- src/plugins/sftp/www/sftp.js | 6 + 7 files changed, 263 insertions(+), 58 deletions(-) diff --git a/src/dialogs/loader.js b/src/dialogs/loader.js index e7a34cea4..dfb98946f 100644 --- a/src/dialogs/loader.js +++ b/src/dialogs/loader.js @@ -6,14 +6,15 @@ import restoreTheme from "lib/restoreTheme"; let loaderIsImmortal = false; let onCancelCallback = null; +let cancelButtonTimeout = null; let $currentDialog = null; let $currentMask = null; const titleLoaderId = "__title-loader"; /** * @typedef {object} LoaderOptions - * @property {number} timeout Timeout in milliseconds after which the loader will be shown - * @property {function():void} oncancel Callback function to be called when the loader is shown + * @property {number} timeout Delay before the cancel button is shown, in milliseconds + * @property {function():void} oncancel Callback invoked only when the user cancels */ /** @@ -64,19 +65,21 @@ function create(titleText, message = "", options = {}) { ); - const { timeout, oncancel } = options; - if (typeof oncancel === "function") { - onCancelCallback = oncancel; - } + clearTimeout(cancelButtonTimeout); + cancelButtonTimeout = null; + onCancelCallback = + typeof options.oncancel === "function" ? options.oncancel : null; - if (typeof timeout === "number") { - setTimeout(() => { + if (typeof options.timeout === "number") { + cancelButtonTimeout = setTimeout(() => { + cancelButtonTimeout = null; + if (!$dialog.isConnected) return; $dialog.append(
- +
, ); - }, timeout); + }, options.timeout); } if (!$oldLoader) { @@ -98,6 +101,13 @@ function create(titleText, message = "", options = {}) { }; } +function cancel() { + const callback = onCancelCallback; + onCancelCallback = null; + destroy(); + callback?.(); +} + function createTitleLoader() { const $titleLoader = tag.get(`#${titleLoaderId}`) || ( @@ -116,6 +126,9 @@ function createTitleLoader() { function destroy() { const loaderDiv = tag.get("#__loader"); const mask = tag.get("#__loader-mask"); + clearTimeout(cancelButtonTimeout); + cancelButtonTimeout = null; + onCancelCallback = null; restoreTheme(); if (!loaderDiv && !mask) { @@ -128,7 +141,6 @@ function destroy() { actionStack.unfreeze(); if (loaderDiv?.isConnected) loaderDiv.remove(); if (mask?.isConnected) mask.remove(); - onCancelCallback?.(); }, 300); } diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 41bff24af..803be8af4 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -407,6 +407,23 @@ class SftpClient { } } + /** + * Tests a profile once without the normal remote-filesystem retry policy. + * @param {string} requestID Native request ID used for cancellation + */ + testConnection(requestID) { + return new Promise((resolve, reject) => { + sftp.testProfile(this.#profileID, requestID, 10000, resolve, reject); + }); + } + + /** Cancel an in-flight profile test. */ + cancelConnection(requestID) { + return new Promise((resolve) => { + sftp.cancelConnection(requestID, resolve, resolve); + }); + } + async #connectWithRetry() { const attempts = settings.value.retryRemoteFsAfterFail ? this.#MAX_TRY + 1 diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js index 83c8077b7..967f5fcb5 100644 --- a/src/lib/remoteStorage.js +++ b/src/lib/remoteStorage.js @@ -35,7 +35,7 @@ export default { try { loader.create(strings["add ftp"], strings["connecting..."], { timeout: 10000, - callback() { + oncancel() { stopConnection = true; }, }); @@ -176,6 +176,8 @@ export default { existingProfile = null, } = {}) { let stopConnection = false; + let connection; + const connectionRequestID = `add-sftp-${helpers.uuid()}`; if (existingProfile?.profileId) { try { const saved = await getSftpProfileInfo(existingProfile.profileId); @@ -242,17 +244,21 @@ export default { const url = createSftpProfileUrl(profile.profileId); loader.create(strings["add sftp"], strings["connecting..."], { - timeout: 10000, - callback() { + timeout: 0, + oncancel() { stopConnection = true; + connection?.cancelConnection(connectionRequestID); }, }); - const connection = Sftp(null, 22, null, { + connection = Sftp(null, 22, null, { profileID: profile.profileId, }); try { - const [home] = await Promise.all([connection.pwd(), loadAd()]); + const [home] = await Promise.all([ + connection.testConnection(connectionRequestID), + loadAd(), + ]); if (stopConnection) { stopConnection = false; diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index c7c6ffaf9..b26758b18 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1468,8 +1468,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const timeout = setTimeout(() => { loader.create(name, strings.loading + "...", { timeout: loaderTimeout, - callback() { - loader.destroy(); + oncancel() { navigate("/", "/"); progress[id] = false; }, diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts index dc75b3536..ca7db1d09 100644 --- a/src/plugins/sftp/index.d.ts +++ b/src/plugins/sftp/index.d.ts @@ -42,6 +42,10 @@ interface Sftp { exec(command: String, onSucess: (res: ExecResult)=>void, onFail: (err: any) => void): void; /** Connects using credentials held by the native profile store. */ connectUsingProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void; + /** Tests a profile once and returns its remote working directory. */ + testProfile(profileId: String, requestId: String, timeout: Number, onSuccess: (home: String) => void, onFail: (err: any) => void): void; + /** Cancels an in-flight profile connection or test. */ + cancelConnection(requestId: String, onSuccess: () => void, onFail: (err: any) => void): void; saveProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profileId: String) => void, onFail: (err: any) => void): void; editProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void; getProfileInfo(profileId: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void; diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index b676c1f05..5f478097d 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -53,6 +53,8 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.cordova.CallbackContext; @@ -72,8 +74,13 @@ public class Sftp extends CordovaPlugin { // A smaller mobile window prevents connection bursts from exhausting the heap. private static final long SFTP_MAX_WINDOW_SIZE = 1024L * 1024L; private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L; + private static final long DEFAULT_CONNECT_TIMEOUT_MS = 10000L; + private static final long MIN_CONNECT_TIMEOUT_MS = 1000L; + private static final long MAX_CONNECT_TIMEOUT_MS = 30000L; private static boolean cryptoProviderConfigured; private final Object connectionLock = new Object(); + private final Map connectionAttempts = + new ConcurrentHashMap<>(); private final Map remoteShells = new ConcurrentHashMap<>(); private SshClient ssh; private SftpClient sftp; @@ -82,6 +89,50 @@ public class Sftp extends CordovaPlugin { private String connectionID; private SftpSecurityStore securityStore; + private final class ConnectionAttempt { + + private final CallbackContext callback; + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final AtomicBoolean completed = new AtomicBoolean(false); + private volatile Future future; + + private ConnectionAttempt(CallbackContext callback) { + this.callback = callback; + } + + private void setFuture(Future future) { + this.future = future; + if (cancelled.get()) future.cancel(true); + } + + private boolean isCancelled() { + return cancelled.get(); + } + + private void cancel() { + cancelled.set(true); + Future current = future; + if (current != null) current.cancel(true); + error(connectionCancelledError()); + } + + private void success() { + if (completed.compareAndSet(false, true)) callback.success(); + } + + private void success(String value) { + if (completed.compareAndSet(false, true)) callback.success(value); + } + + private void error(String message) { + if (completed.compareAndSet(false, true)) callback.error(message); + } + + private void error(JSONObject error) { + if (completed.compareAndSet(false, true)) callback.error(error); + } + } + private final class ConnectionSecurity { private final String hostname; @@ -162,12 +213,17 @@ public boolean verifyHost(String host, SshPublicKey publicKey) } private boolean report(CallbackContext callback) { - JSONObject error = failure; - failure = null; + JSONObject error = takeFailure(); if (error == null) return false; callback.error(error); return true; } + + private JSONObject takeFailure() { + JSONObject error = failure; + failure = null; + return error; + } } private final class RemoteShell { @@ -318,11 +374,16 @@ private void closeConnectionQuietly() { private boolean establishConnection( SshClientBuilder builder, String newConnectionID, - ConnectionSecurity security + ConnectionSecurity security, + ConnectionAttempt attempt ) throws IOException, SshException, PermissionDeniedException { synchronized (connectionLock) { closeConnectionQuietly(); ssh = builder.onConfigure(security::configure).build(); + if (attempt.isCancelled()) { + closeConnectionQuietly(); + return false; + } if (!ssh.isConnected()) { closeConnectionQuietly(); return false; @@ -336,6 +397,11 @@ private boolean establishConnection( throw e; } + if (attempt.isCancelled()) { + closeConnectionQuietly(); + return false; + } + try { sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); } catch (UnsupportedEncodingException | SshException e) { @@ -371,6 +437,17 @@ private static JSONObject hostKeyFailure( return error; } + private static JSONObject connectionCancelledError() { + JSONObject error = new JSONObject(); + try { + error.put("code", "SFTP_CONNECT_CANCELLED"); + error.put("message", "SFTP connection cancelled"); + error.put("cancelled", true); + error.put("nonRetryable", true); + } catch (JSONException ignored) {} + return error; + } + private boolean confirmUnknownHost( String endpoint, String algorithm, @@ -440,23 +517,40 @@ private void closeRemoteShells() { remoteShells.clear(); } + private void cancelConnectionAttempts() { + for (ConnectionAttempt attempt : connectionAttempts.values()) { + attempt.cancel(); + } + connectionAttempts.clear(); + } + @Override public void onReset() { + cancelConnectionAttempts(); closeRemoteShells(); super.onReset(); } @Override public void onDestroy() { + cancelConnectionAttempts(); closeRemoteShells(); super.onDestroy(); } private SshClientBuilder buildProfileBuilder(JSONObject profile) throws IOException, InvalidPassphraseException, JSONException { + return buildProfileBuilder(profile, DEFAULT_CONNECT_TIMEOUT_MS); + } + + private SshClientBuilder buildProfileBuilder( + JSONObject profile, + long connectTimeout + ) throws IOException, InvalidPassphraseException, JSONException { SshClientBuilder builder = SshClientBuilder.create() .withHostname(profile.getString("hostname")) .withPort(profile.optInt("port", 22)) + .withConnectTimeout(connectTimeout) .withUsername(profile.getString("username")); if ("key".equals(profile.optString("authType"))) { @@ -868,6 +962,8 @@ private static boolean isAllowedAction(String action) { switch (action) { case "exec": case "connectUsingProfile": + case "testProfile": + case "cancelConnection": case "saveProfile": case "editProfile": case "getProfileInfo": @@ -894,47 +990,112 @@ private static boolean isAllowedAction(String action) { } public void connectUsingProfile(JSONArray args, CallbackContext callback) { - cordova - .getThreadPool() - .execute( - new Runnable() { - public void run() { - ConnectionSecurity security = null; - String profileID = args.optString(0); - try { - JSONObject profile = securityStore.getProfile(profileID); - ConnectionSecurity profileSecurity = new ConnectionSecurity( - profile.getString("hostname"), - profile.optInt("port", 22) - ); - security = profileSecurity; - if ( - establishConnection( - buildProfileBuilder(profile), - profileID, - profileSecurity - ) - ) { - callback.success(); - return; - } - if (security.report(callback)) return; - callback.error("Failed to establish SSH connection"); - } catch (InvalidPassphraseException e) { - callback.error("Invalid passphrase for stored key"); - } catch (Exception e) { - if (security != null && security.report(callback)) return; - callback.error("Failed to connect SFTP profile: " + errMessage(e)); - Log.e(TAG, "Failed to connect SFTP profile", e); - } catch (OutOfMemoryError e) { - synchronized (connectionLock) { - closeConnectionQuietly(); + startProfileConnection(args, callback, false); + } + + public void testProfile(JSONArray args, CallbackContext callback) { + startProfileConnection(args, callback, true); + } + + public void cancelConnection(JSONArray args, CallbackContext callback) { + ConnectionAttempt attempt = connectionAttempts.get(args.optString(0)); + if (attempt != null) attempt.cancel(); + callback.success(); + } + + private void startProfileConnection( + JSONArray args, + CallbackContext callback, + boolean returnWorkingDirectory + ) { + String suppliedRequestID = args.optString(1); + String requestID = suppliedRequestID.isEmpty() + ? UUID.randomUUID().toString() + : suppliedRequestID; + long requestedTimeout = args.optLong(2, DEFAULT_CONNECT_TIMEOUT_MS); + long connectTimeout = Math.max( + MIN_CONNECT_TIMEOUT_MS, + Math.min(requestedTimeout, MAX_CONNECT_TIMEOUT_MS) + ); + ConnectionAttempt attempt = new ConnectionAttempt(callback); + if (connectionAttempts.putIfAbsent(requestID, attempt) != null) { + callback.error("An SFTP connection with this request ID is already running"); + return; + } + + FutureTask task = new FutureTask( + () -> { + ConnectionSecurity security = null; + String profileID = args.optString(0); + try { + JSONObject profile = securityStore.getProfile(profileID); + ConnectionSecurity profileSecurity = new ConnectionSecurity( + profile.getString("hostname"), + profile.optInt("port", 22) + ); + security = profileSecurity; + if ( + establishConnection( + buildProfileBuilder(profile, connectTimeout), + profileID, + profileSecurity, + attempt + ) + ) { + if (attempt.isCancelled()) return null; + if (returnWorkingDirectory) { + SftpClient activeSftp = sftp; + if (activeSftp == null) { + attempt.error("SFTP connection was closed before validation"); + } else { + attempt.success(activeSftp.pwd()); } - callback.error("Not enough memory to initialize SFTP"); + } else { + attempt.success(); } + return null; + } + if (attempt.isCancelled()) return null; + JSONObject securityError = security.takeFailure(); + if (securityError != null) { + attempt.error(securityError); + return null; + } + attempt.error("Failed to establish SSH connection"); + } catch (InvalidPassphraseException e) { + if (!attempt.isCancelled()) { + attempt.error("Invalid passphrase for stored key"); + } + } catch (Exception e) { + if (attempt.isCancelled()) return null; + JSONObject securityError = security == null + ? null + : security.takeFailure(); + if (securityError != null) { + attempt.error(securityError); + return null; + } + attempt.error("Failed to connect SFTP profile: " + errMessage(e)); + Log.e(TAG, "Failed to connect SFTP profile", e); + } catch (OutOfMemoryError e) { + synchronized (connectionLock) { + closeConnectionQuietly(); + } + if (!attempt.isCancelled()) { + attempt.error("Not enough memory to initialize SFTP"); } } - ); + return null; + } + ) { + @Override + protected void done() { + connectionAttempts.remove(requestID, attempt); + } + }; + + attempt.setFuture(task); + cordova.getThreadPool().execute(task); } public void exec(JSONArray args, CallbackContext callback) { diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js index 1fbbb76e3..855cf1cdf 100644 --- a/src/plugins/sftp/www/sftp.js +++ b/src/plugins/sftp/www/sftp.js @@ -5,6 +5,12 @@ module.exports = { connectUsingProfile: function (profileId, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingProfile', [profileId]); }, + testProfile: function (profileId, requestId, timeout, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'testProfile', [profileId, requestId, timeout]); + }, + cancelConnection: function (requestId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'cancelConnection', [requestId]); + }, saveProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'saveProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]); }, From 8c826777d75a61172cd80d682bd30e522fe30e92 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:04:34 +0530 Subject: [PATCH 2/2] fix race cases --- .../sftp/src/com/foxdebug/sftp/Sftp.java | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index 5f478097d..a72466607 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -1034,22 +1034,42 @@ private void startProfileConnection( profile.optInt("port", 22) ); security = profileSecurity; - if ( - establishConnection( - buildProfileBuilder(profile, connectTimeout), + SshClientBuilder builder = buildProfileBuilder( + profile, + connectTimeout + ); + boolean connected; + String workingDirectory = null; + if (returnWorkingDirectory) { + synchronized (connectionLock) { + connected = establishConnection( + builder, + profileID, + profileSecurity, + attempt + ); + if (connected && !attempt.isCancelled()) { + SftpClient activeSftp = sftp; + if (activeSftp == null) { + throw new IOException( + "SFTP connection was closed before validation" + ); + } + workingDirectory = activeSftp.pwd(); + } + } + } else { + connected = establishConnection( + builder, profileID, profileSecurity, attempt - ) - ) { + ); + } + if (connected) { if (attempt.isCancelled()) return null; if (returnWorkingDirectory) { - SftpClient activeSftp = sftp; - if (activeSftp == null) { - attempt.error("SFTP connection was closed before validation"); - } else { - attempt.success(activeSftp.pwd()); - } + attempt.success(workingDirectory); } else { attempt.success(); }