commit 4247db5bed8c7abc8ffb2544b6c05312ab36f146
parent cadc70c5435d9e75acf10ac5383ff1bd139da23d
Author: Iván Ávalos <avalos@disroot.org>
Date: Tue, 18 Aug 2026 11:20:19 +0200
[wallet] answer every HTTP request wallet-core makes
Diffstat:
1 file changed, 96 insertions(+), 70 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/backend/NetworkInterface.kt b/wallet/src/main/java/net/taler/wallet/backend/NetworkInterface.kt
@@ -21,23 +21,25 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.*
import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.header
-import io.ktor.client.request.headers
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.util.flattenForEach
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
-import kotlinx.serialization.SerializationException
import net.taler.common.getDefaultHttpClient
import net.taler.common.toHttpMethod
import net.taler.qtart.Networking
import net.taler.wallet.main.TAG
-import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+/** The HTTP client wallet-core drives instead of doing its own networking.
+ * Every request must be answered exactly once: wallet-core suspends a promise per
+ * request with no timeout of its own, so an unanswered request hangs the wallet. */
@OptIn(DelicateCoroutinesApi::class)
class NetworkInterface: Networking.RequestHandler {
private val requests: ConcurrentHashMap<Int, Job> = ConcurrentHashMap()
@@ -55,92 +57,117 @@ class NetworkInterface: Networking.RequestHandler {
id: Int,
sendResponse: (resp: Networking.ResponseInfo) -> Unit
) {
- Log.d(TAG, "HTTP: handleRequest($req, $id")
+ Log.d(TAG, "HTTP: handleRequest($req, $id)")
requests[id] = GlobalScope.launch {
- val client = if (req.redirectMode == Networking.RedirectMode.Transparent) {
- redirectClient
- } else {
- sharedClient
+ val answered = AtomicBoolean(false)
+ fun answer(
+ status: Int,
+ error: String?,
+ respHeaders: Array<String>,
+ respBody: ByteArray?,
+ ) {
+ if (answered.compareAndSet(false, true)) {
+ sendResponse(
+ Networking.ResponseInfo(
+ requestId = id,
+ status = status,
+ errorMsg = error,
+ headers = respHeaders,
+ body = respBody,
+ )
+ )
+ }
}
+ try {
+ val client = if (req.redirectMode == Networking.RedirectMode.Transparent) {
+ redirectClient
+ } else {
+ sharedClient
+ }
- var errorMsg: String? = null
-
- val resp = try {
- client.request {
- url(req.url)
-
- method = req.method.toHttpMethod()
- ?: error("invalid method")
+ val method = req.method.toHttpMethod()
+ if (method == null) {
+ // Reporting it as a transport failure is the only way to tell wallet-core.
+ answer(0, "unsupported HTTP method '${req.method}'", emptyArray(), null)
+ return@launch
+ }
- headers {
- parseHeaders(req.headers).map {
- header(it.key, it.value)
+ var errorMsg: String? = null
+ val resp = try {
+ client.request {
+ url(req.url)
+ this.method = method
+ parseHeaders(req.headers).forEach { (k, v) -> header(k, v) }
+ if (req.body != null) {
+ setBody(req.body)
}
- }
-
- if (req.body != null) {
- setBody(req.body)
- }
-
- timeout {
- val t = req.timeoutMs
- if (t > 0) {
- requestTimeoutMillis = t
- socketTimeoutMillis = t
+ timeout {
+ val t = req.timeoutMs
+ if (t > 0) {
+ requestTimeoutMillis = t
+ socketTimeoutMillis = t
+ }
}
}
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: ResponseException) {
+ // Send non-200 responses on: a 402 from a sync provider is a normal part
+ // of the backup protocol, not a failure.
+ e.response
+ } catch (e: Throwable) {
+ Log.d(TAG, "Exception performing HTTP request", e)
+ errorMsg = e.message ?: e.toString()
+ null
}
- } catch (e: ResponseException) {
- e.response // send non-200 responses to wallet-core anyway
- } catch (e: IOException) {
- Log.d(TAG, "Exception handling HTTP response", e)
- errorMsg = e.message
- null
- } catch (e: SerializationException) {
- Log.d(TAG, "Exception handling HTTP response", e)
- errorMsg = e.message
- null
+
+ // Body reads can fail on their own; keep them inside the catch.
+ val body: ByteArray? = if (resp == null) null else try {
+ resp.body<ByteArray>()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ Log.d(TAG, "Exception reading HTTP response body", e)
+ errorMsg = e.message ?: e.toString()
+ null
+ }
+
+ val respHeaders = mutableListOf<String>().apply {
+ resp?.headers?.flattenForEach { k, v -> add("$k: $v") }
+ }.toTypedArray()
+
+ Log.d(TAG, "Sending response to wallet-core")
+ answer(resp?.status?.value ?: 0, errorMsg, respHeaders, body)
+ } catch (e: CancellationException) {
+ // wallet-core cancelled the request; do not answer it.
+ throw e
+ } catch (e: Throwable) {
+ Log.e(TAG, "Unexpected failure handling HTTP request $id", e)
+ answer(0, e.message ?: e.toString(), emptyArray(), null)
} finally {
cleanupRequest(id)
}
-
- // HTTP response status code or 0 on error.
- val status = if (resp?.status?.value != null) resp.status.value else 0
-
- val headers = mutableListOf<String>().apply {
- resp?.headers?.flattenForEach { k, v -> add("$k: $v") }
- }.toTypedArray()
-
- Log.d(TAG, "Sending response to wallet-core")
- sendResponse(
- Networking.ResponseInfo(
- requestId = id,
- status = status,
- errorMsg = errorMsg,
- headers = headers,
- body = resp?.body(),
- )
- )
}
}
- private fun parseHeaders(headers: Array<String>) = headers.associate {
- val parts = it.split(':', limit = 2)
- parts[0] to parts[1]
- }
-
- override fun cancelRequest(id: Int): Boolean {
- Log.d(TAG, "HTTP: cancelRequest($id")
- requests[id]?.let { job ->
- job.cancel()
- requests.remove(id)
+ /** Splits `"Name: value"` header lines; a line without a colon is skipped, never thrown. */
+ private fun parseHeaders(headers: Array<String>): List<Pair<String, String>> =
+ headers.mapNotNull {
+ val parts = it.split(':', limit = 2)
+ if (parts.size < 2) {
+ Log.w(TAG, "ignoring malformed request header '$it'")
+ null
+ } else parts[0].trim() to parts[1].trim()
}
+ override fun cancelRequest(id: Int): Boolean {
+ Log.d(TAG, "HTTP: cancelRequest($id)")
+ requests.remove(id)?.cancel()
return true
}
private fun cleanupRequest(id: Int) {
requests.remove(id)
}
-}
-\ No newline at end of file
+}