I am trying to make a connection with an FTP server (vsftpd with SSL/TLS configured including implicit passive mode) and list all files in the "." directory. I use Android Studio with Kotlin.
Whenever I invoke the enqueueTask(workManager)
and wait 10 seconds (initial delay of the worker), the FTP client connects to the vsftpd server succesfully but upon giving wrong user and pass credentials (which leads to the code throwing ConnectException
due to loggedIn
being false) the exception is not re-thrown immediately and only thrown after some minutes of waiting.
Why is this behaviour occuring and how to resolve it such that the exception is thrown as soon as it occurs so that it can be caught by the withContext(Dispatchers.IO)
for retry?
build.gradle
commons-net/workmanager dependencies:
dependencies {
implementation 'commons-net:commons-net:3.6'
implementation 'androidx.work:work-runtime-ktx:2.5.0'
}
PeriodicWorker
class:
class PeriodicWorker(
appContext: Context,
private val workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val TAG: String = "PeriodicWorker"
private const val TIME: Long = 3
private val UNIT_TIME: TimeUnit = TimeUnit.HOURS
fun enqueueTask(workManager: WorkManager) {
val periodicWork = PeriodicWorkRequestBuilder<PeriodicWorker>(
TIME, UNIT_TIME
)
.setInitialDelay(10, TimeUnit.SECONDS) // for testing purposes
.setBackoffCriteria(
BackoffPolicy.LINEAR,
PeriodicWorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.SECONDS
)
.build()
workManager.enqueueUniquePeriodicWork("unique", ExistingPeriodicWorkPolicy.KEEP, periodicWork)
}
}
private val ftpHandler: FtpHandler = FtpHandler()
override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) {
try {
val filesInFtpServer: Array<String>? = ftpHandler.listFileNamesFtpServer() // Note: Should throw exception but doesn't immediately do that
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
}
ftpHandler
class:
class FtpHandler {
companion object {
private const val DATA_TIMEOUT_MS = 1000 * 5
private const val CONNECTION_TIMEOUT_MS = 1000 * 5
private const val TAG = "FtpHandler"
}
private val client: FTPSClient = FTPSClient("TLS", true)
private val ftpHost = ""
private val ftpPort = 21
private val ftpUsername = "wrong_user"
private val ftpPassword = "wrong_pass"
init {
client.setDataTimeout(DATA_TIMEOUT_MS)
client.connectTimeout = CONNECTION_TIMEOUT_MS
}
private fun login(): Boolean {
// Establish a connection with the FTP Server
client.connect(ftpHost, ftpPort)
client.execPBSZ(0)
client.execPROT("P")
client.enterLocalPassiveMode()
// Login with given username and pass
return client.login(ftpUsername, ftpPassword)
}
fun listFileNamesFtpServer(directory: String = "."): Array<String>? {
var res: Array<String>? = null
try {
val loggedIn: Boolean = login()
if (loggedIn) {
if (FTPReply.isPositiveCompletion(client.replyCode)) {
client.changeWorkingDirectory(directory)
res = client.listNames()
} else {
throw ConnectException("Reply: ${client.reply}")
}
} else {
throw ConnectException("Reply: ${client.reply}")
}
} catch (e: Exception) {
throw e
} finally {
try {
client.logout()
client.disconnect()
} catch (e: IOException) {
throw e
}
}
return res
}
}
I suspect it has to do with the timeouts of the FTP socket that might be interleaved at some points of the execution of login
function. I tried using another test function that does dummy http request and waits 3000 milliseconds until it throws an Exception and even that immediately triggered exception and caught in the context Dispatchers.IO.
UPDATE:
I used the android studio debugger to see how the problem can be reproduced. If the FTP server is not online, the FTPS client would successfully throw SocketTimeoutException as expected in the coroutine scope.
However, when the FTP is online then the FTPS client IS able to make a connection it does not throw the mentioned exception but just hangs for a minute or two and then throws the exception. In this case, the vsftpd server responds with "530 Login incorrect."
but the FTPSClient of commons-net does not disconnect afterwards.
UPDATE 2:
I added 5 seconds timeout to socket: client.soTimeout = 5 * 1000
before calling return client.login(ftpUsername, ftpPassword)
.
Doing that, the exception thrown is java.net.SocketTimeoutException: Read timed out
after the throw ConnectException()
is thrown when loggedIn
is false.
I find this strange. Why doesn't listFileNamesFtpServer
immediately execute throw e
after ConnectException
is thrown? Why is it that it waits for a read from FTP server and when socket timeout is reached it goes into the catch block of the method which propagates the exception successfully to the coroutine call?
So the culprit of the hanging behaviour is that the FTP socket was trying to read a reply from the FTP server while there wasn't any. The exception got thrown later on since I use the .reply
function of FTPSClient within the exception string. When the exception was thrown hence depended on the So_timeout
value of the ftpclient.
Solution: instead use getReplyCode
, getReplyString
, and getReplyStrings
(https://commons.apache.org/proper/commons-net/apidocs/org/apache/commons/net/ftp/FTPClient.html) To get the latest replycode/string/strings that the FTP responded in the latest response.
I don't know the exact reason why they allowed the user to use the reply function and did not make it private or protected instead.
Thanks android studio debugger, @ltp and @Tenfour04 for your efforts!