From 9c0188b424292cab679f484e75bc6c42f93759e0 Mon Sep 17 00:00:00 2001 From: Jeremy Bernstein Date: Wed, 13 May 2026 09:17:39 +0200 Subject: [PATCH] fix(steam/download): close race that hangs Update/Verify at 0% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit downloadJobs[appId] = info was assigned AFTER scope.launch{} kicked off the work. on a fast verify/update (game already on disk, DepotDownloader has no chunks to fetch) the launched coroutine could complete and call removeDownloadJob(appId) BEFORE the outer assignment ran. removeDownloadJob found no entry, skipped notifyDownloadStopped, then the outer code populated the map with a stale completed-job DownloadInfo and emitted notifyDownloadStarted. UI hung at 0% with no stop event ever coming. cancel was a no-op too — DownloadInfo.downloadJob may still have been null (setDownloadJob hadn't run), and even when set the job was already complete. retry returned the same stale DownloadInfo from downloadJobs via the line ~1666 short-circuit. fix: - register downloadJobs[appId] + notifyDownloadStarted BEFORE launching the coroutine, so the inline removeDownloadJob inside the launch body always finds the entry and emits DownloadStatusChanged(false). - invokeOnCompletion now unconditionally calls removeDownloadJob as a safety net for paths the inline call doesn't cover (early return on empty licenses, exceptions before the catch handlers, cancellations out of suspension points). second call is a no-op. event ordering for the success path is preserved: inline removeDownloadJob fires first (DownloadStatusChanged false), then LibraryInstallStatusChanged, then the invokeOnCompletion no-op. --- .../java/app/gamenative/service/SteamService.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 8be93599f0..7751686223 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1767,6 +1767,15 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("Resumed download: initialized with $persistedBytes bytes") } + // register BEFORE launching: a fast verify/update on up-to-date data can + // complete the launch body synchronously (DepotDownloader has no chunks to + // fetch). removeDownloadJob inside that body must find the entry to emit + // DownloadStatusChanged(false); otherwise the UI hangs at 0% and the next + // downloadApp call returns the stale DownloadInfo from the still-populated + // map (line ~1666 short-circuit). + downloadJobs[appId] = di + notifyDownloadStarted(appId) + val downloadJob = instance!!.scope.launch { try { // Get licenses from database @@ -2034,16 +2043,18 @@ class SteamService : Service(), IChallengeUrlChanged { } } downloadJob.invokeOnCompletion { throwable -> + // safety net for paths the inline removeDownloadJob doesn't cover: + // early `return@launch` on empty licenses, exceptions before the catch + // handlers, and cancellations thrown out of suspension points. + // second call is a no-op if the inline path already removed the entry. + removeDownloadJob(appId) if (throwable is kotlinx.coroutines.CancellationException) { Timber.d(throwable, "Download canceled for app $appId") - removeDownloadJob(appId) } } di.setDownloadJob(downloadJob) } - downloadJobs[appId] = info - notifyDownloadStarted(appId) return info }