Compare commits

...
Author SHA1 Message Date
MaxsTechReview bd36f6f691 Merge branch 'main' into fix/steam-client-download 2026-10-11 15:53:38 -04:00
MaxsTechReview 2ce979d74d Merge branch 'main' into fix/steam-client-download 2026-10-11 15:51:21 -04:00
maxjivi05 98e9043dd0 fix: a first run's Steam client download is not paused out of sight
With the default background behavior (Manual), a session pauses as soon
as DroidDeck is hidden or the screen turns off - one second after start in
the report this came from, before the download had begun. "Keep downloads
running" did not help: it is off by default, and it only recognizes the
client's own game downloads, not the runtime fetching the client itself.

Until the session shows its first frame, the download watch now also
asks whether the client is still to be fetched (no install stamp or no
client binary, or a repair asked for). While it is, the session is kept
awake with its wake and Wi-Fi locks whatever the downloads setting says,
the notification reads "Steam download active" with Suspend now, and a
steam.client_download event marks start and end. The watch is set up
before the first suspend decision, so a session started with the screen
off is not paused first. Once the client is there, the chosen behavior
applies again.
2026-10-11 14:45:57 -04:00
maxjivi05 0402561fdd fix: the Steam client download resumes, retries and shows megabytes
A first run downloads the native client, about 360 MB in 17 parts, before
anything is on screen. The script ran plain `curl -fsSL` per part: no
timeout, no retry, no progress. A stalled connection could wait for ever
on "downloading Steam: bins_linuxarm64_linuxarm64 (2/17)" (a 110 MB part),
a failed one ended the session and threw away everything fetched, and
the loading screen said "~1-2 min" while counting parts, not bytes.

Parts now go to package/droiddeck-download and stay there until the client
is installed. Each one resumes from the bytes on disk (curl -C -), a
transfer under 4 KB/s for a minute is dropped and resumed, attempts
without progress back off 5-30 s before giving up, and a finished part is
marked unpacked so a later session skips it. Bytes that fail their
checksum are fetched whole again; a server that will not resume starts the
part over; parts left for another client version are dropped. A repair
redoes every part once and keeps what an interrupted repair redid.

Every 5 s the step line carries megabytes overall ("(2/17), 45 of 360
MB"), which the loading screen shows as "45 / 360 MB" with the bar
following the bytes. The session's step no longer promises 1-2 minutes.
2026-10-11 14:45:57 -04:00
8 changed files with 404 additions and 42 deletions

No files matched your search

@@ -102,11 +102,10 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
if (line.startsWith("== STEP ")) {
stepAt = at
stepText = line.substringAfter("== STEP ").substringAfter(' ')
val m = INSTALL_COUNT.find(line)
if (m != null) {
clientDownload(line)?.let { (what, percent) ->
clientDownloadAt = at
clientDownload = m.groupValues[1]
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
clientDownload = what
clientPercent = percent
}
} else {
val m = UPDATE_PROGRESS.find(line)
@@ -152,6 +151,21 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
}
}
/**
* What a Steam client download line says to show and how far it is: megabytes when the
* runtime counts them ("<part> (2/17), 45 of 360 MB"), else the part and its number.
*/
internal fun clientDownload(line: String): Pair<String, Int>? {
val count = INSTALL_COUNT.find(line) ?: return null
val mb = INSTALL_MEGABYTES.find(line.substring(count.range.last + 1))
val done = mb?.groupValues?.get(1)?.toLongOrNull()
val total = mb?.groupValues?.get(2)?.toLongOrNull()
if (done != null && total != null && total > 0) {
return "$done / $total MB" to (done * 100 / total).toInt().coerceIn(0, 100)
}
return count.groupValues[1] to count.groupValues[2].toInt() * 100 / maxOf(1, count.groupValues[3].toInt())
}
/** The script's install lines are worth reading as they are; its other notes are not. */
internal fun readableScriptStep(scriptStep: String): Boolean {
val t = scriptStep.lowercase()
@@ -161,5 +175,6 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
private val INSTALL_MEGABYTES = Regex("""^, (\d+) of (\d+) MB""")
}
}
@@ -83,6 +83,12 @@ class SessionService : Service() {
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
/**
* The runtime is fetching the Steam client itself (a first run, a repair), before anything is on
* screen. That download keeps the session awake whatever the downloads setting says: paused, a
* first run never gets as far as showing Steam.
*/
private var clientInstallActive = false
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
@@ -229,6 +235,7 @@ class SessionService : Service() {
suspendDownloadNow = false
steamDownloadMonitor.reset()
downloadActive = false
clientInstallActive = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
@@ -550,8 +557,9 @@ class SessionService : Service() {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
// Watching first: a first run's client download must be known before anything is paused.
updateDownloadMonitoring()
mainHandler.post { updateSuspendPolicy() }
}
}
@@ -1331,8 +1339,7 @@ class SessionService : Service() {
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
val downloadsBlockingSuspend = downloadsKeepAwake() && hidden && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
completeNativeSleep()
@@ -1709,8 +1716,7 @@ class SessionService : Service() {
.setContentText(getString(
when {
SessionState.suspended -> R.string.session_notification_paused
downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
downloadsKeepAwake() -> R.string.session_notification_downloads
else -> R.string.session_notification
}
))
@@ -1718,8 +1724,7 @@ class SessionService : Service() {
.apply {
if (SessionState.suspended) {
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
} else if (downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
} else if (downloadsKeepAwake()) {
val suspend = PendingIntent.getService(
this@SessionService, 3,
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
@@ -1739,31 +1744,51 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
/** A download is under way that holds the session awake while it is out of sight. */
private fun downloadsKeepAwake(): Boolean =
downloadActive && (steamDownloadsInBackground || clientInstallActive) &&
SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER
/**
* Steam's own downloads with the downloads setting on, and the Steam client's download until the
* session shows its first frame whatever the setting: once Steam is on screen it was fetched.
*/
private fun downloadWatchWanted(): Boolean =
SessionState.running && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER &&
(steamDownloadsInBackground || !SessionState.firstFrameSeen)
private fun updateDownloadMonitoring() {
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
if (!enabled) {
if (!downloadWatchWanted()) {
val wasActive = downloadActive
downloadMonitorTask?.let(mainHandler::removeCallbacks)
downloadMonitorTask = null
steamDownloadMonitor.reset()
downloadActive = false
clientInstallActive = false
if (wasActive) refreshNotification()
return
}
if (downloadMonitorTask != null) return
val poll = object : Runnable {
override fun run() {
if (downloadMonitorTask !== this || !SessionState.running ||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
if (downloadMonitorTask !== this) return
if (!downloadWatchWanted()) {
downloadMonitorTask = null
updateDownloadMonitoring()
updateSuspendPolicy()
return
}
val root = LinuxRuntime.rootDir(this@SessionService)
val wasActive = downloadActive
downloadActive = steamDownloadMonitor.poll(
LinuxRuntime.rootDir(this@SessionService),
val wasInstalling = clientInstallActive
clientInstallActive = !SessionState.firstFrameSeen && SteamDownloadMonitor.clientInstallPending(root)
downloadActive = clientInstallActive || steamDownloadsInBackground && steamDownloadMonitor.poll(
root,
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
)
if (wasInstalling != clientInstallActive) {
SessionEvents.record("steam.client_download", mapOf("active" to clientInstallActive))
}
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
@@ -126,13 +126,26 @@ internal class SteamDownloadMonitor {
private fun value(text: String, key: String): String? =
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
private companion object {
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
companion object {
/**
* The runtime has yet to fetch the Steam client: a first run, or a repair asked for or begun
* (droiddeck-steam-install drops its stamp as it starts one). That download runs before
* anything is on screen, and a paused session pauses it with the rest.
*/
fun clientInstallPending(runtimeRoot: File): Boolean {
val home = File(runtimeRoot, "root")
val steam = File(home, ".local/share/Steam")
val stamped = File(steam, "package/droiddeck-installed").isFile ||
File(steam, "package/bannerlator-installed").isFile
return !(stamped && File(steam, "steamrtarm64/steam").isFile) || File(home, ".bl-steam-repair").exists()
}
private const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
private const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
private const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
private val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
private val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
private val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
}
@@ -0,0 +1,37 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoadingStateTest {
@Test fun clientDownloadCountsMegabytesWhenTheRuntimeSaysThem() {
assertEquals(
"45 / 360 MB" to 12,
LoadingState.clientDownload("== STEP 17:38:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB"),
)
}
@Test fun aStalledDownloadStillShowsWhereItIs() {
assertEquals(
"45 / 360 MB" to 12,
LoadingState.clientDownload(
"== STEP 17:39:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB" +
" - interrupted (curl exit 28), trying again in 5 s",
),
)
}
@Test fun anOlderRuntimeShowsThePartAndItsNumber() {
assertEquals(
"bins_linuxarm64_linuxarm64" to 11,
LoadingState.clientDownload("== STEP 17:38:17 downloading Steam: bins_linuxarm64_linuxarm64 (2/17)"),
)
// Under a megabyte in all: nothing to count yet.
assertEquals("a_all" to 50, LoadingState.clientDownload("== STEP 00:00:00 downloading Steam: a_all (1/2), 0 of 0 MB"))
}
@Test fun otherStepsAreNotClientDownloads() {
assertNull(LoadingState.clientDownload("== STEP 17:38:14 checking the Steam client (a first run downloads it)"))
}
}
@@ -126,6 +126,39 @@ class SteamDownloadMonitorTest {
}
}
@Test fun theClientIsPendingUntilItIsInstalledAndStamped() {
val root = Files.createTempDirectory("droiddeck-steam-client-").toFile()
try {
val steam = File(root, "root/.local/share/Steam")
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
File(steam, "steamrtarm64").mkdirs()
File(steam, "steamrtarm64/steam").writeText("client")
// Unpacked but not finished: droiddeck-steam-install stamps the client last.
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
File(steam, "package").mkdirs()
File(steam, "package/droiddeck-installed").writeText("1791686101\n")
assertFalse(SteamDownloadMonitor.clientInstallPending(root))
} finally {
root.deleteRecursively()
}
}
@Test fun anOlderStampCountsAndARepairRequestIsPending() {
val root = Files.createTempDirectory("droiddeck-steam-client-").toFile()
try {
val steam = File(root, "root/.local/share/Steam")
File(steam, "steamrtarm64").mkdirs()
File(steam, "steamrtarm64/steam").writeText("client")
File(steam, "package").mkdirs()
File(steam, "package/bannerlator-installed").writeText("1\n")
assertFalse(SteamDownloadMonitor.clientInstallPending(root))
File(root, "root/.bl-steam-repair").writeText("1\n")
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
} finally {
root.deleteRecursively()
}
}
private fun manifest(root: File, appId: String, downloaded: Long, total: Long, flags: Int): File {
val file = File(root, "steamapps/appmanifest_$appId.acf")
check(file.parentFile?.mkdirs() != false)
@@ -400,7 +400,9 @@ case $mode in
esac
;;
steam)
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
# A first run downloads the client (about 360 MB in 2026): minutes on a slow connection, with
# the progress in megabytes on the loading screen.
step "checking the Steam client (a first run downloads it)"
droiddeck-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
@@ -12,6 +12,9 @@ stamp=$steam_root/package/droiddeck-installed
legacy_stamp=$steam_root/package/bannerlator-installed
unconfirmed=$steam_root/package/droiddeck-unconfirmed
repair_request=$HOME/.bl-steam-repair
# The parts of an unfinished download: a session that ends, or loses its connection, part way
# through picks up where it stopped instead of fetching the whole client again.
parts=$steam_root/package/droiddeck-download
if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then
mv -f "$legacy_stamp" "$stamp" || true
fi
@@ -24,6 +27,11 @@ elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \
fi
if [ -n "$repair" ]; then
echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)"
# A repair unpacks every part again - once: an interrupted one keeps what it has redone.
if [ ! -f "$parts/.repair" ]; then
rm -rf "$parts"
mkdir -p "$parts" && : > "$parts/.repair"
fi
rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \
"$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed
for part in "$steam_root"/package/*.zip.*; do
@@ -41,26 +49,105 @@ fi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl -fsSL "$cdn/$manifest" -o "$work/manifest"
# A sleep that starts no process (droiddeck-session's nap): read -t on a pipe nothing writes to.
nap() {
[ -n "${nap_fd:-}" ] || exec {nap_fd}<> <(:)
read -r -t "$1" -u "$nap_fd" _ 2>/dev/null || :
}
size_of() {
local n=0
[ -f "$1" ] && n=$(wc -c < "$1")
echo $((n))
}
# A connection can stall without closing (Wi-Fi power save, a phone changing networks), and
# curl's default then is to wait for ever with nothing on screen. A transfer under 4 KB/s for a
# minute is dropped and resumed from the bytes already on disk.
limits=(--connect-timeout 30 --speed-limit 4096 --speed-time 60)
# Seconds between attempts that made no progress; the download fails after the last one.
read -r -a delays <<< "${BL_STEAM_RETRY_DELAYS:-5 10 20 30 30}"
step() { echo "== STEP $(date -u +%H:%M:%S) $*"; }
# The line the app's loading screen reads: the part, its number, and megabytes overall. A third
# argument is said after it (a stall), and is always said; otherwise only a change is.
progress() {
local mb=$(( (done_bytes + $(size_of "$1")) / 1000000 ))
[ -z "${3:-}" ] && [ "$mb" = "${shown_mb:-}" ] && [ "$2" = "${shown_part:-}" ] && return 0
shown_mb=$mb shown_part=$2
step "downloading Steam: $2 ($i/$total), $mb of $((total_bytes / 1000000)) MB${3:+ - $3}"
}
# fetch URL FILE SIZE SHA256 PART: FILE complete and checked, resuming whatever is already there.
fetch() {
local url=$1 dest=$2 size=$3 sum=$4 part=$5 failures=0 bad=0 finished=0 before rc fd wait
while :; do
# Whole once it is as long as the manifest says, or (no size given) once curl finished it.
if [ -f "$dest" ] && { [ "$finished" = 1 ] || { [ "$size" -gt 0 ] && [ "$(size_of "$dest")" -ge "$size" ]; }; }; then
echo "$sum $dest" | sha256sum -c --quiet 2>/dev/null && return 0
# Bytes that do not check out are fetched whole again, a few times at most.
rm -f "$dest"
finished=0 bad=$((bad + 1))
[ "$bad" -lt 3 ] || { echo "droiddeck-steam-install: $part does not match its checksum" >&2; return 1; }
fi
before=$(size_of "$dest")
# curl's status comes back through the pipe (|| keeps set -e from ending the subshell before it
# is said); until then a progress line every 5 s.
exec {fd}< <(curl -fsSL "${limits[@]}" -C - -o "$dest" "$url" && echo 0 || echo $?)
until read -r -t 5 -u "$fd" rc; do
if [ $? -le 128 ]; then rc=1; break; fi
progress "$dest" "$part"
done
exec {fd}<&-
if [ "$rc" = 0 ]; then finished=1; continue; fi
# 33: the server would not resume. Start the part over.
[ "$rc" = 33 ] && rm -f "$dest"
if [ "$(size_of "$dest")" -gt "$before" ]; then failures=0; else failures=$((failures + 1)); fi
if [ "$failures" -gt "${#delays[@]}" ]; then
echo "droiddeck-steam-install: could not download $part (curl exit $rc); what arrived is kept for the next try" >&2
return 1
fi
wait=${delays[$((failures > 0 ? failures - 1 : 0))]}
progress "$dest" "$part" "interrupted (curl exit $rc), trying again in ${wait} s"
nap "$wait"
done
}
curl -fsSL "${limits[@]}" --retry 5 --retry-connrefused --retry-delay 5 "$cdn/$manifest" -o "$work/manifest"
version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
# Every component block: its name, then the plain zip's file name and sha256.
# Every component block: its name, then the plain zip's file name, sha256 and size.
awk -F'"' '
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
$2 == "file" { file[block] = $4 }
$2 == "sha2" { sha[block] = $4 }
END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] }
$2 == "size" { size[block] = $4 }
END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b], size[b] + 0 }
' "$work/manifest" | sort > "$work/parts"
[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; }
total=$(grep -c . "$work/parts" 2>/dev/null || echo 0)
total_bytes=$(awk '{ n += $4 } END { print n + 0 }' "$work/parts")
mkdir -p "$steam_root/package"
while read -r block file sum; do
mkdir -p "$parts"
# What an earlier attempt left for another client version is no use to this one. A part's file
# name carries its own hash, so a name still listed is the same part.
for left in "$parts"/*; do
[ -e "$left" ] || continue
name=${left##*/}
name=${name%.unpacked}
grep -qF " $name " "$work/parts" || rm -rf "$left"
done
done_bytes=0
while read -r block file sum size; do
i=$((${i:-0} + 1))
echo "== STEP $(date -u +%H:%M:%S) downloading Steam: $block ($i/$total)"
curl -fsSL "$cdn/$file" -o "$work/$file"
echo "$sum $work/$file" | sha256sum -c --quiet
unzip -qo "$work/$file" -d "$steam_root"
rm -f "$work/$file"
if [ -f "$parts/$file.unpacked" ]; then
done_bytes=$((done_bytes + size))
continue
fi
progress "$parts/$file" "$block"
fetch "$cdn/$file" "$parts/$file" "$size" "$sum" "$block" || exit 1
unzip -qo "$parts/$file" -d "$steam_root"
: > "$parts/$file.unpacked"
rm -f "$parts/$file"
done_bytes=$((done_bytes + size))
done < "$work/parts"
# Some entries are zipped with Windows separators; unzip keeps the backslash in the file name.
find "$steam_root" -depth -name '*\\*' | while read -r odd; do
@@ -90,6 +177,7 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64"
ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64"
ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32"
echo "$version" > "$stamp"
rm -rf "$parts"
if [ -n "$repair" ]; then
echo repaired > "$unconfirmed"
rm -f "$repair_request"
+154 -5
View File
@@ -26,13 +26,43 @@ class SteamInstallTest(unittest.TestCase):
self.bin = self.root / 'bin'
self.bin.mkdir()
# Only the network is faked: exercise real manifest parsing, checksums and zip extraction.
# TEST_PLAN lines "<file> <action>" are used up in order by requests for that file:
# "cut <bytes>" delivers up to that offset then drops the connection, "fail <status>"
# delivers nothing, "norange" refuses to resume.
curl = self.bin / 'curl'
curl.write_text('''#!/bin/bash
set -eu
printf '%s\\n' "$2" >> "$TEST_REQUESTS"
cp "$TEST_CDN/${2##*/}" "$4"
curl.write_text('''#!/usr/bin/env python3
import os, sys
args = sys.argv[1:]
url = next(a for a in args if a.startswith('https://'))
out = args[args.index('-o') + 1]
name = url.rsplit('/', 1)[1]
offset = os.path.getsize(out) if '-C' in args and os.path.exists(out) else 0
with open(os.environ['TEST_REQUESTS'], 'a') as log:
log.write(url + '\\n')
with open(os.environ['TEST_REQUESTS'] + '.offsets', 'a') as log:
log.write(f'{name} {offset}\\n')
action = []
plan = os.environ.get('TEST_PLAN')
if plan and os.path.exists(plan):
lines = open(plan).read().splitlines()
for n, line in enumerate(lines):
if line.split()[0] == name:
action = line.split()[1:]
del lines[n]
open(plan, 'w').write(''.join(l + '\\n' for l in lines))
break
if action[:1] == ['fail']:
sys.exit(int(action[1]))
if action[:1] == ['norange'] and offset:
sys.exit(33)
data = open(os.path.join(os.environ['TEST_CDN'], name), 'rb').read()
end = int(action[1]) if action[:1] == ['cut'] else len(data)
with open(out, 'ab' if offset else 'wb') as target:
target.write(data[offset:end])
sys.exit(28 if end < len(data) else 0)
''')
curl.chmod(0o755)
self.plan = self.root / 'plan'
# macOS has shasum; the guest and Linux CI have sha256sum.
if shutil.which('sha256sum') is None:
sha = self.bin / 'sha256sum'
@@ -44,7 +74,8 @@ cp "$TEST_CDN/${2##*/}" "$4"
sha1.chmod(0o755)
self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data),
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests))
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests),
TEST_PLAN=str(self.plan), BL_STEAM_RETRY_DELAYS='0 0 0')
for channel in ('steamdeck_publicbeta',):
archive = self.cdn / (channel + '.zip')
with zipfile.ZipFile(archive, 'w') as package:
@@ -157,5 +188,123 @@ cp "$TEST_CDN/${2##*/}" "$4"
self.assertFalse(self.requests.exists())
def publish(self, **parts):
"""The CDN's client as these parts (block name -> byte count of incompressible content),
listed with sizes as Valve's manifest lists them."""
files = {}
blocks = ''
for block, length in parts.items():
archive = self.cdn / f'{block}.zip.{hashlib.sha1(block.encode()).hexdigest()}'
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_STORED) as package:
package.writestr(f'parts/{block}', os.urandom(length))
if block.endswith('_linuxarm64_linuxarm64'):
package.writestr('steamrtarm64/steam', 'steamdeck_publicbeta')
data = archive.read_bytes()
files[block] = archive.name
blocks += (f'\t"{block}"\n\t{{\n\t\t"file"\t\t"{archive.name}"\n\t\t"size"\t\t"{len(data)}"\n'
f'\t\t"sha2"\t\t"{hashlib.sha256(data).hexdigest()}"\n\t}}\n')
(self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text(
'"linuxarm64"\n{\n\t"version"\t\t"123456"\n' + blocks + '}\n')
return files
def two_parts(self):
return self.publish(a_all=1_200_000, client_linuxarm64_linuxarm64=1_300_000)
def plan_for(self, name, *actions):
with self.plan.open('a') as plan:
plan.writelines(f'{name} {action}\n' for action in actions)
def offsets(self, name):
log = Path(str(self.requests) + '.offsets')
return [int(line.split()[1]) for line in log.read_text().splitlines() if line.split()[0] == name]
def requested(self):
return [line.rsplit('/', 1)[1] for line in self.requests.read_text().splitlines()]
@property
def download(self):
return self.steam / 'package/droiddeck-download'
def test_progress_is_counted_in_megabytes_across_parts(self):
self.two_parts()
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn('downloading Steam: a_all (1/2), 0 of 2 MB\n', result.stdout)
self.assertIn('downloading Steam: client_linuxarm64_linuxarm64 (2/2), 1 of 2 MB\n', result.stdout)
self.assertTrue((self.steam / 'parts/a_all').is_file())
self.assertFalse(self.download.exists())
def test_a_dropped_connection_resumes_from_what_arrived(self):
files = self.two_parts()
client = files['client_linuxarm64_linuxarm64']
self.plan_for(client, 'cut 500000')
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([0, 500000], self.offsets(client))
self.assertIn('interrupted (curl exit 28), trying again in 0 s', result.stdout)
self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text())
def test_a_failed_session_keeps_what_it_finished_and_fetched(self):
files = self.two_parts()
client = files['client_linuxarm64_linuxarm64']
self.plan_for(client, 'cut 400000', *['fail 6'] * 4)
result = self.install()
self.assertNotEqual(0, result.returncode)
self.assertIn('could not download client_linuxarm64_linuxarm64 (curl exit 6)', result.stderr)
self.assertFalse((self.steam / 'package/droiddeck-installed').exists())
self.assertTrue((self.download / (files['a_all'] + '.unpacked')).is_file())
self.assertFalse((self.download / files['a_all']).exists())
self.assertEqual(400000, (self.download / client).stat().st_size)
self.requests.unlink()
Path(str(self.requests) + '.offsets').unlink()
self.assertEqual(0, self.install().returncode)
# The next session asks only for the rest of the part it did not finish.
self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', client], self.requested())
self.assertEqual([400000], self.offsets(client))
self.assertFalse(self.download.exists())
def test_a_server_that_will_not_resume_starts_the_part_over(self):
files = self.two_parts()
client = files['client_linuxarm64_linuxarm64']
self.plan_for(client, 'cut 400000', 'norange')
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([0, 400000, 0], self.offsets(client))
def test_bytes_that_do_not_check_out_are_fetched_again(self):
files = self.two_parts()
client = files['client_linuxarm64_linuxarm64']
self.download.mkdir(parents=True)
(self.download / client).write_bytes(b'x' * (self.cdn / client).stat().st_size)
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([0], self.offsets(client))
def test_parts_left_for_another_client_version_are_dropped(self):
files = self.two_parts()
self.download.mkdir(parents=True)
(self.download / 'a_all.zip.0123').write_bytes(b'older')
(self.download / 'b_all.zip.4567.unpacked').write_bytes(b'')
self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 4)
self.assertNotEqual(0, self.install().returncode)
self.assertEqual([files['a_all'] + '.unpacked'], sorted(p.name for p in self.download.iterdir()))
def test_a_repair_unpacks_every_part_again_once(self):
files = self.two_parts()
self.assertEqual(0, self.install().returncode)
(self.home / '.bl-steam-repair').write_text('1\n')
self.requests.unlink()
self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 4)
self.assertNotEqual(0, self.install().returncode)
self.assertIn(files['a_all'], self.requested())
self.requests.unlink()
# Still a repair, but what the interrupted one redid is not done a third time.
self.assertEqual(0, self.install().returncode)
self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', files['client_linuxarm64_linuxarm64']],
self.requested())
self.assertFalse((self.home / '.bl-steam-repair').exists())
self.assertFalse(self.download.exists())
if __name__ == '__main__':
unittest.main()