From 0fe0b65467e7f15a008b797ecac09a7dcc72e4a4 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Thu, 22 Oct 2026 12:27:25 -0700 Subject: [PATCH] android: resolve LocalAPI backend through Client LocalAPI requests currently rely on a static application reference populated by Request.setApp() during backend initialization. We introduced a lazy init in https://github.com/tailscale/tailscale-android/commit/c56420bbc19166cf3dc0659b7e2de705270c751a to ensure we weren't making LocalAPI calls before initialization, but the property is never accessed, and as a result, requests issued during a cold start can fail before the backend is initialized. This change has Client provide libtailscale to each request instead of relying on global state, making Client responsible for supplying the dependency rather than requiring requests initialize themselves. When each request executes, we get the current libtailscale application instance (or initialize if necessary). Fixes tailscale/tailscale#20884 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 2 - .../com/tailscale/ipn/ui/localapi/Client.kt | 27 ++++--- .../tailscale/ipn/ui/localapi/ClientTest.kt | 81 +++++++++++++++++++ 3 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index f6242769ed..5bfc09d42f 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -28,7 +28,6 @@ import androidx.security.crypto.MasterKey import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.FavoritesManager @@ -255,7 +254,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { fun startLibtailscale(directFileRoot: String, hardwareAttestation: Boolean) { app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, hardwareAttestation, this) ShareFileHelper.init(this, app, directFileRoot, applicationScope) - Request.setApp(app) Notifier.setApp(app) Notifier.start(applicationScope) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 146a1a0463..64749ce308 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -68,12 +68,12 @@ typealias PingResultHandler = (Result) -> Unit * Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a * corresponding method on this Client. */ -class Client(private val scope: CoroutineScope) { +class Client( + private val scope: CoroutineScope, + private val appProvider: () -> libtailscale.Application = { App.get().getLibtailscaleApp() }, +) { private val TAG = Client::class.simpleName - // Access libtailscale.Application lazily - private val app: libtailscale.Application by lazy { App.get().getLibtailscaleApp() } - fun start(options: Ipn.Options, responseHandler: (Result) -> Unit) { val body = Json.encodeToString(options).toByteArray() return post(Endpoint.START, body, responseHandler = responseHandler) @@ -213,6 +213,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "GET", path = path, body = body, @@ -229,6 +230,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "PUT", path = path, body = body, @@ -246,6 +248,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "POST", path = path, body = body, @@ -263,6 +266,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "POST", path = path, parts = parts, @@ -280,6 +284,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "PATCH", path = path, body = body, @@ -295,6 +300,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "DELETE", path = path, responseType = typeOf(), @@ -304,8 +310,9 @@ class Client(private val scope: CoroutineScope) { } } -class Request( +private class Request( private val scope: CoroutineScope, + private val appProvider: () -> libtailscale.Application, private val method: String, path: String, private val body: ByteArray? = null, @@ -320,20 +327,14 @@ class Request( private const val TAG = "LocalAPIRequest" private val jsonDecoder = Json { ignoreUnknownKeys = true } - - private lateinit var app: libtailscale.Application - - @JvmStatic - fun setApp(newApp: libtailscale.Application) { - app = newApp - } } @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { - TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { + val app = appProvider() + TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") val resp = if (parts != null) app.callLocalAPIMultipart( diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt new file mode 100644 index 0000000000..50db892f68 --- /dev/null +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt @@ -0,0 +1,81 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.localapi + +import android.util.Log +import com.tailscale.ipn.ui.model.IpnState +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.mock + +class ClientTest { + private lateinit var androidLog: MockedStatic + private lateinit var originalLogWrapper: TSLog.LibtailscaleWrapper + + @Before + fun setUp() { + androidLog = Mockito.mockStatic(Log::class.java) + originalLogWrapper = TSLog.libtailscaleWrapper + TSLog.libtailscaleWrapper = mock() + } + + @After + fun tearDown() { + TSLog.libtailscaleWrapper = originalLogWrapper + androidLog.close() + } + + @Test + fun constructingClientDoesNotResolveApp() = runTest { + var calls = 0 + Client(this) { + calls++ + throw IllegalStateException("Unexpected initialization") + } + assertEquals(0, calls) + } + + @Test + fun providerFailureIsDeliveredToCallback() = runBlocking { + val failure = IllegalStateException("Backend initialization failed") + val response = CompletableDeferred>() + val client = Client(this) { throw failure } + + client.status { response.complete(it) } + + val result = withTimeout(5_000) { response.await() } + assertSame(failure, result.exceptionOrNull()) + } + + @Test + fun providerIsResolvedForEachRequest() = runBlocking { + val failures = + listOf( + IllegalStateException("First backend"), + IllegalStateException("Second backend"), + ) + var calls = 0 + val client = Client(this) { throw failures[calls++] } + + suspend fun request(): Result { + val response = CompletableDeferred>() + client.status { response.complete(it) } + return withTimeout(5_000) { response.await() } + } + + assertSame(failures[0], request().exceptionOrNull()) + assertSame(failures[1], request().exceptionOrNull()) + assertEquals(2, calls) + } +}