fix: disconnect desktop download on session cancel

This commit is contained in:
xXJsonDeruloXx committed 2026-10-22 09:56:05 -04:00
1 parent 4e4a731221
commit c02832ac59
6 files changed
+84 -7

No files matched your search

@@ -1436,9 +1436,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun installDesktop(): String? {
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this)
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, { SessionState.stopRequested },
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested }
SessionState.installCancellation = cancellation
return try {
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, cancellation,
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
problem?.let { getString(R.string.session_desktop_install_failed, it) }
} finally {
if (SessionState.installCancellation === cancellation) SessionState.installCancellation = null
}
}
private fun installProton(): String? {
@@ -15,6 +15,8 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class Downloader {
private static final String TAG = "Downloader";
@@ -24,6 +26,32 @@ public final class Downloader {
private Downloader() {}
public static final class Cancellation implements java.util.function.BooleanSupplier {
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicReference<HttpURLConnection> connection = new AtomicReference<>();
private final java.util.function.BooleanSupplier external;
public Cancellation() { this(null); }
public Cancellation(java.util.function.BooleanSupplier external) { this.external = external; }
public void cancel() {
cancelled.set(true);
HttpURLConnection active = connection.get();
if (active != null) active.disconnect();
}
@Override public boolean getAsBoolean() {
return cancelled.get() || (external != null && external.getAsBoolean());
}
private void attach(HttpURLConnection active) {
connection.set(active);
if (getAsBoolean()) active.disconnect();
}
private void detach(HttpURLConnection active) { connection.compareAndSet(active, null); }
}
public static String downloadString(String url) {
List<String> sources = DownloadSource.INSTANCE.candidates(url);
for (int i = 0; i < sources.size(); i++) {
@@ -182,6 +210,7 @@ public final class Downloader {
have = 0;
}
connection = open(url, STALL_TIMEOUT_MS);
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(connection);
if (have > 0) {
connection.setRequestProperty("Range", "bytes=" + have + "-");
connection.setRequestProperty("If-Range", identity.etag);
@@ -245,7 +274,10 @@ public final class Downloader {
Log.w(TAG, "download " + url, e);
return Outcome.RETRY;
} finally {
if (connection != null) connection.disconnect();
if (connection != null) {
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(connection);
connection.disconnect();
}
}
}
@@ -86,7 +86,10 @@ object DesktopCatalog {
install(context, entry, { false }, listener)
/** A cancel stops the download and is checked once more before anything on disk changes; extraction itself runs to the end. */
fun install(context: Context, entry: Entry, cancelled: () -> Boolean, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, cancelled: () -> Boolean, listener: LinuxRuntimeInstaller.ProgressListener?): String? =
install(context, entry, Downloader.Cancellation(cancelled), listener)
fun install(context: Context, entry: Entry, cancelled: Downloader.Cancellation, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
@@ -1733,6 +1733,7 @@ class SessionService : Service() {
fun stop(context: Context) {
if (!SessionState.running) {
SessionState.stopRequested = true
SessionState.installCancellation?.cancel()
return
}
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
@@ -36,6 +36,9 @@ object SessionState {
@Volatile
var installing: String? = null
@Volatile
var installCancellation: com.droiddeck.launcher.core.Downloader.Cancellation? = null
@Volatile
var stopRequested = false
@@ -6,6 +6,9 @@ import java.net.Socket
import java.nio.file.Files
import java.security.MessageDigest
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
@@ -29,6 +32,9 @@ class DownloaderResumeTest {
@Volatile private var misbehave = false
@Volatile private var etag: String? = "\"v1\""
@Volatile private var corrupt = false
@Volatile private var holdAfterFirstChunk = false
private var bodyStarted = CountDownLatch(1)
private var continueBody = CountDownLatch(1)
@Before fun setUp() {
dir = Files.createTempDirectory("downloader-resume").toFile()
@@ -70,8 +76,15 @@ class DownloaderResumeTest {
val start = if (from == null) "200 OK" else "206 Partial Content"
val extra = if (from == null) "" else "Content-Range: bytes 0-${data.size - 1}/${data.size}\r\n"
out.write("HTTP/1.1 $start\r\n${extra}${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray())
out.write(content)
out.flush()
if (holdAfterFirstChunk) {
out.write(content, 0, 1024)
out.flush()
bodyStarted.countDown()
continueBody.await(5, TimeUnit.SECONDS)
} else {
out.write(content)
out.flush()
}
} else if (from == null) {
out.write("HTTP/1.1 200 OK\r\n${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray())
out.write(content, 0, cutAt)
@@ -181,6 +194,25 @@ class DownloaderResumeTest {
assertTrue("the transfer stopped early (kept=${target.length()})", target.length() in 1 until data.size)
}
@Test fun cancellationDisconnectsAStalledTransfer() {
val url = "http://127.0.0.1:${server.localPort}/first"
val target = File(dir, "runtime.tar.zst")
misbehave = true
holdAfterFirstChunk = true
val cancellation = Downloader.Cancellation()
val executor = Executors.newSingleThreadExecutor()
try {
val result = executor.submit<Boolean> { Downloader.downloadFile(url, target, true, null, cancellation, null) }
assertTrue("server sent the first chunk", bodyStarted.await(2, TimeUnit.SECONDS))
cancellation.cancel()
assertFalse("cancelled download", result.get(2, TimeUnit.SECONDS))
assertEquals(listOf<String?>(null), requests.toList())
} finally {
continueBody.countDown()
executor.shutdownNow()
}
}
@Test fun aCancelBeforeTheFirstRequestSendsNothing() {
val url = "http://127.0.0.1:${server.localPort}/runtime.tar.zst"
val target = File(dir, "runtime.tar.zst")