diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 360380956..6e447339d 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -350,6 +350,8 @@ class MainActivity : ComponentActivity() { private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var steamDownloadsInBackground by mutableStateOf(false) + private var zramCompression by mutableStateOf(false) + private var zramSupported by mutableStateOf(null) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var backActionsInverted by mutableStateOf(false) private var gameStorage by mutableStateOf("") @@ -1293,6 +1295,8 @@ class MainActivity : ComponentActivity() { touchMode = touchMode, suspendPolicy = suspendPolicy, steamDownloadsInBackground = steamDownloadsInBackground, + zramCompression = if (mode == SessionService.MODE_STEAM) zramCompression else null, + zramSupported = zramSupported, pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this), pipAutoEnter = pipAutoEnter, unfoldedControls = if (mode == SessionService.MODE_STEAM && @@ -1351,6 +1355,7 @@ class MainActivity : ComponentActivity() { SessionPrefs.setSteamDownloadsInBackground(this, enabled) steamDownloadsInBackground = enabled }, + onZramCompression = { on -> SessionPrefs.setZramCompression(this, on); zramCompression = on }, onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on }, onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, @@ -1539,6 +1544,9 @@ class MainActivity : ComponentActivity() { // The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the // added-games folders, the storage volumes) lands while it rises. Thread({ + val zram = com.droiddeck.launcher.core.ZramSupport.supported() + val zramOn = SessionPrefs.zramCompression(this) + ui.post { zramSupported = zram; zramCompression = zramOn } drivers.refreshDrivers() val storage = GameStorage.options(this) val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null diff --git a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt new file mode 100644 index 000000000..e81ed9681 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt @@ -0,0 +1,35 @@ +package com.droiddeck.launcher.core + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import java.io.File + +/** Whether the client's memory can be paged out to compressed swap: MADV_PAGEOUT (Linux 5.4) and a zram or other swap device. */ +object ZramSupport { + private const val GIB = 1L shl 30 + + fun supported(): Boolean = kernelAtLeast(System.getProperty("os.version").orEmpty(), 5, 4) && swapTotalKb() > 0 + + fun recommended(context: Context): Boolean { + if (!supported()) return false + val memory = ActivityManager.MemoryInfo() + context.getSystemService(ActivityManager::class.java).getMemoryInfo(memory) + val advertised = if (Build.VERSION.SDK_INT >= 34) memory.advertisedMem else 0L + return belowTwelveGb(advertised, memory.totalMem) + } + + fun belowTwelveGb(advertisedBytes: Long, totalBytes: Long): Boolean = + if (advertisedBytes > 0) advertisedBytes < 11 * GIB else totalBytes < 10 * GIB + + fun kernelAtLeast(release: String, major: Int, minor: Int): Boolean { + val parts = Regex("^(\\d+)\\.(\\d+)").find(release)?.groupValues ?: return false + val ma = parts[1].toInt() + val mi = parts[2].toInt() + return ma > major || (ma == major && mi >= minor) + } + + fun swapTotalKb(meminfo: List = runCatching { File("/proc/meminfo").readLines() }.getOrDefault(emptyList())): Long = + meminfo.firstOrNull { it.startsWith("SwapTotal:") } + ?.substringAfter(':')?.trim()?.substringBefore(' ')?.toLongOrNull() ?: 0L +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index ff286eebc..b941508d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -7,6 +7,7 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.core.CpuAllocationDefaults import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.TextureFiltering +import com.droiddeck.launcher.core.ZramSupport import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.ScreenEffects import org.json.JSONObject @@ -1057,6 +1058,16 @@ object SessionPrefs { }.apply() } + /** Steam only: the client's memory goes to the device's zram while a game runs (ZramSupport). */ + fun zramCompression(context: Context): Boolean { + val prefs = prefs(context) + return if (prefs.contains("zramCompression")) prefs.getBoolean("zramCompression", false) else ZramSupport.recommended(context) + } + + fun setZramCompression(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("zramCompression", on).apply() + } + // ── Game storage ──────────────────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 01f9112eb..f068b8f26 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -660,6 +660,8 @@ class SessionService : Service() { // gamescope's realtime Vulkan queues (the session script turns this into // GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on. guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0")) + if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) && + com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") guest.add("BANNER_AUDIO_DIRECT_DECAY=0") // Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the // app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 2ec97d7a7..3a03fe305 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -70,6 +70,10 @@ class ModeSettings( val touchMode: String, val suspendPolicy: String, val steamDownloadsInBackground: Boolean = false, + /** Steam only; null hides it. */ + val zramCompression: Boolean? = null, + /** Null until checked off the main thread. */ + val zramSupported: Boolean? = null, val pipSupported: Boolean = false, val pipAutoEnter: Boolean = false, /** Steam only. */ @@ -127,6 +131,7 @@ class ModeSettingsActions( val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onSteamDownloadsInBackground: (Boolean) -> Unit = {}, + val onZramCompression: (Boolean) -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, val onOsc: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit = {}, @@ -303,6 +308,12 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_background_downloads_hint), s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground, ) + if (s.zramCompression != null) ToggleRow( + host, "zram", stringResource(R.string.mode_zram), + stringResource(if (s.zramSupported == false) R.string.mode_zram_unsupported else R.string.mode_zram_hint), + s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true, + onChange = a.onZramCompression, + ) } if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb1765615..3c192317e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,6 +278,9 @@ Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running. Keep downloads running Delay background suspension while Steam is actively downloading. + zRAM compression + While a game runs, the Steam client\'s memory is compressed into the device\'s zram, leaving more RAM for the game. Applies the next time Steam starts. + Unavailable: this device has no zram or swap, or its kernel is older than 5.4. Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running. Startup Start Steam with DroidDeck diff --git a/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt new file mode 100644 index 000000000..056e3a055 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ZramSupportTest { + @Test + fun kernelVersionsFromAndroidReleases() { + assertTrue(ZramSupport.kernelAtLeast("6.12.38-android16-5-g665eafb62659-ab14778838-4k", 5, 4)) + assertTrue(ZramSupport.kernelAtLeast("5.4.0", 5, 4)) + assertFalse(ZramSupport.kernelAtLeast("4.19.157-perf+", 5, 4)) + assertFalse(ZramSupport.kernelAtLeast("", 5, 4)) + } + + @Test + fun underTwelveGbFromAdvertisedOrTotalMemory() { + val gb = 1_000_000_000L + val gib = 1L shl 30 + assertTrue(ZramSupport.belowTwelveGb(8 * gb, 0)) + assertTrue(ZramSupport.belowTwelveGb(8 * gib, 0)) + assertFalse(ZramSupport.belowTwelveGb(12 * gb, 0)) + assertFalse(ZramSupport.belowTwelveGb(12 * gib, 0)) + assertTrue(ZramSupport.belowTwelveGb(0, 7_400_000L * 1024)) + assertTrue(ZramSupport.belowTwelveGb(0, 9_300_000L * 1024)) + assertFalse(ZramSupport.belowTwelveGb(0, 11_100_000L * 1024)) + assertFalse(ZramSupport.belowTwelveGb(0, 15_600_380L * 1024)) + } + + @Test + fun swapTotalComesFromMeminfo() { + assertEquals(12582908L, ZramSupport.swapTotalKb(listOf("MemTotal: 15600380 kB", "SwapTotal: 12582908 kB"))) + assertEquals(0L, ZramSupport.swapTotalKb(listOf("SwapTotal: 0 kB"))) + assertEquals(0L, ZramSupport.swapTotalKb(emptyList())) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index ba25dcc2a..6eb0059e8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -489,6 +489,60 @@ case $mode in done ) & fi + # zRAM compression (BL_ZRAM): 20 s into a game the client and its web helpers page their memory + # out to the device's zram (libblsession zram.c), again every 5 minutes, and read it back once + # the game has exited. A game is running while Steam's reaper is. + if [ "${BL_ZRAM:-0}" = 1 ]; then + printf 0 > /tmp/.bl-zram + step "zRAM compression: on" + ( + bl_zram_scan() { + _bl_game=0 _bl_pids=() + for _d in /proc/[0-9]*; do + IFS= read -r -d '' _c 2>/dev/null < "$_d/cmdline" || continue + case ${_c##*/} in + reaper) _bl_game=1 ;; + steam|steamwebhelper) _bl_pids+=("${_d#/proc/}") ;; + esac + done + } + bl_zram_usage() { + local _p _k _v _u _rss=0 _swap=0 + for _p in "${_bl_pids[@]}"; do + while read -r _k _v _u; do + case $_k in VmRSS:) _rss=$((_rss + _v)) ;; VmSwap:) _swap=$((_swap + _v)) ;; esac + done 2>/dev/null < "/proc/$_p/status" + done + echo "${#_bl_pids[@]} processes, resident $((_rss / 1024)) MB, in zram $((_swap / 1024)) MB" + } + bl_zram_send() { + step "zRAM: $2 ($(bl_zram_usage))" + printf '%s' "$1" > /tmp/.bl-zram + [ "${#_bl_pids[@]}" = 0 ] || kill -URG "${_bl_pids[@]}" 2>/dev/null + nap 5 + bl_zram_scan + step "zRAM: done ($(bl_zram_usage))" + } + _bl_since=-1 _bl_packed=-1 + while kill -0 $$ 2>/dev/null; do + bl_zram_scan + if [ "$_bl_game" = 1 ]; then + [ "$_bl_since" -ge 0 ] || _bl_since=$SECONDS + if [ $((SECONDS - _bl_since)) -ge 20 ] && [ "${#_bl_pids[@]}" -gt 0 ] && + { [ "$_bl_packed" -lt 0 ] || [ $((SECONDS - _bl_packed)) -ge 300 ]; }; then + bl_zram_send 1 "compressing the client" + _bl_packed=$SECONDS + fi + elif [ "$_bl_since" -ge 0 ]; then + [ "$_bl_packed" -lt 0 ] || bl_zram_send 0 "game closed, reading the client back" + _bl_since=-1 _bl_packed=-1 + fi + nap 5 + done + ) & + else + rm -f /tmp/.bl-zram + fi # Rockstar's launcher keeps drawing its hidden web interface for as long as a game of theirs # runs; droiddeck-launcher-cores holds it on one slow core while it is out of sight, from # gamescope's focus as the agent publishes it. BL_LAUNCHER_CORES=0 leaves launchers alone. diff --git a/tools/linuxfs/preload/zram.c b/tools/linuxfs/preload/zram.c new file mode 100644 index 000000000..058b5bd4f --- /dev/null +++ b/tools/linuxfs/preload/zram.c @@ -0,0 +1,176 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* zRAM for the Steam client while a game runs: on SIGURG the process pages its private anonymous + * memory out (ZR_CTL holds 1) or reads it back (0). Raw syscalls only, as this library wraps + * close(), openat() and syscall(). */ + +#ifndef MADV_PAGEOUT +#define MADV_PAGEOUT 21 +#endif +#ifndef ZR_CTL +#define ZR_CTL "/tmp/.bl-zram" +#endif +#define ZR_SWAPPED (1ULL << 62) +#define ZR_BATCH 512 + +static long (*zr_sys)(long, ...); +static unsigned long zr_page; +static int zr_busy; +static int zr_pending; +static char zr_maps[8192]; +static uint64_t zr_entries[ZR_BATCH]; +static struct iovec zr_iov[ZR_BATCH]; +static char zr_sink[ZR_BATCH]; + +static int zr_open(const char *path) { + return (int)zr_sys(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC, 0); +} + +static int zr_mode(void) { + char c = 0; + int fd = zr_open(ZR_CTL); + if (fd < 0) return -1; + long n = zr_sys(SYS_read, fd, &c, 1); + zr_sys(SYS_close, fd); + return n == 1 && (c == '0' || c == '1') ? c - '0' : -1; +} + +static unsigned long zr_hex(const char **p) { + unsigned long v = 0; + for (;; (*p)++) { + char c = **p; + if (c >= '0' && c <= '9') v = v * 16 + (unsigned long)(c - '0'); + else if (c >= 'a' && c <= 'f') v = v * 16 + (unsigned long)(c - 'a' + 10); + else return v; + } +} + +/* Paged out: private writable anonymous memory (the heap, unnamed and [anon:name] mappings). + * Read back: every private readable mapping, as the kernel may have swapped others meanwhile. */ +static int zr_eligible(const char *line, int compress, unsigned long *start, unsigned long *end) { + const char *p = line; + *start = zr_hex(&p); + if (*p++ != '-') return 0; + *end = zr_hex(&p); + if (*p++ != ' ' || p[0] != 'r' || p[3] != 'p') return 0; + if (!compress) return 1; + if (p[1] != 'w') return 0; + for (int field = 0; field < 4; field++) { + while (*p && *p != ' ') p++; + while (*p == ' ') p++; + } + return *p == 0 || strncmp(p, "[heap]", 6) == 0 || strncmp(p, "[anon:", 6) == 0; +} + +static void zr_read_back(int pagemap, unsigned long start, unsigned long end) { + pid_t self = (pid_t)zr_sys(SYS_getpid); + struct iovec local = {zr_sink, 0}; + for (unsigned long addr = start; addr < end;) { + unsigned long count = (end - addr) / zr_page; + if (count > ZR_BATCH) count = ZR_BATCH; + if (zr_sys(SYS_lseek, pagemap, (long)(addr / zr_page * sizeof(uint64_t)), SEEK_SET) < 0) return; + long got = zr_sys(SYS_read, pagemap, zr_entries, count * sizeof(uint64_t)); + if (got < (long)sizeof(uint64_t)) return; + unsigned long n = 0; + for (unsigned long i = 0; i < (unsigned long)got / sizeof(uint64_t); i++) { + if (!(zr_entries[i] & ZR_SWAPPED)) continue; + zr_iov[n].iov_base = (void *)(addr + i * zr_page); + zr_iov[n++].iov_len = 1; + } + for (unsigned long done = 0; done < n;) { + local.iov_len = n - done; + long r = zr_sys(SYS_process_vm_readv, self, &local, 1UL, zr_iov + done, n - done, 0UL); + done += r > 0 ? (unsigned long)r : 1; + } + addr += (unsigned long)got / sizeof(uint64_t) * zr_page; + } +} + +static void zr_walk(int compress) { + int maps = zr_open("/proc/self/maps"); + if (maps < 0) return; + int pagemap = compress ? -1 : zr_open("/proc/self/pagemap"); + if (!compress && pagemap < 0) { + zr_sys(SYS_close, maps); + return; + } + size_t have = 0; + for (;;) { + long n = zr_sys(SYS_read, maps, zr_maps + have, sizeof(zr_maps) - 1 - have); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + have += (size_t)n; + zr_maps[have] = 0; + char *line = zr_maps; + char *nl; + while ((nl = memchr(line, '\n', have - (size_t)(line - zr_maps))) != NULL) { + *nl = 0; + unsigned long s, e; + if (zr_eligible(line, compress, &s, &e) && e > s) { + if (compress) zr_sys(SYS_madvise, s, e - s, MADV_PAGEOUT); + else zr_read_back(pagemap, s, e); + } + line = nl + 1; + } + have -= (size_t)(line - zr_maps); + memmove(zr_maps, line, have); + if (have == sizeof(zr_maps) - 1) have = 0; + } + if (pagemap >= 0) zr_sys(SYS_close, pagemap); + zr_sys(SYS_close, maps); +} + +static void zr_handler(int sig, siginfo_t *info, void *context) { + (void)sig; + (void)context; + if (info == NULL || info->si_code != SI_USER) return; + int saved = errno; + __atomic_store_n(&zr_pending, 1, __ATOMIC_SEQ_CST); + while (__atomic_load_n(&zr_pending, __ATOMIC_SEQ_CST) + && !__atomic_exchange_n(&zr_busy, 1, __ATOMIC_SEQ_CST)) { + __atomic_store_n(&zr_pending, 0, __ATOMIC_SEQ_CST); + int mode = zr_mode(); + if (mode >= 0) zr_walk(mode); + __atomic_store_n(&zr_busy, 0, __ATOMIC_SEQ_CST); + } + errno = saved; +} + +static void zr_child(void) { + __atomic_store_n(&zr_busy, 0, __ATOMIC_SEQ_CST); + __atomic_store_n(&zr_pending, 0, __ATOMIC_SEQ_CST); +} + +__attribute__((constructor)) static void zr_init(void) { + const char *name = program_invocation_short_name; + if (strcmp(name, "steam") != 0 && strcmp(name, "steamwebhelper") != 0) return; + void *libc = dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD); + if (libc == NULL) return; + zr_sys = (long (*)(long, ...))dlsym(libc, "syscall"); + if (zr_sys == NULL) return; + int fd = zr_open(ZR_CTL); + if (fd < 0) return; + zr_sys(SYS_close, fd); + long page = sysconf(_SC_PAGESIZE); + zr_page = page > 0 ? (unsigned long)page : 4096UL; + struct sigaction old; + if (sigaction(SIGURG, NULL, &old) != 0) return; + if ((old.sa_flags & SA_SIGINFO) || old.sa_handler != SIG_DFL) return; + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = zr_handler; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGURG, &sa, NULL) == 0) pthread_atfork(NULL, NULL, zr_child); +} diff --git a/tools/tests/test_zram_preload.py b/tools/tests/test_zram_preload.py new file mode 100644 index 000000000..b0967cf62 --- /dev/null +++ b/tools/tests/test_zram_preload.py @@ -0,0 +1,176 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *ctl; +static size_t size, page; +static volatile int stop; +static unsigned char *shared; + +static long status(const char *key) { + FILE *f = fopen("/proc/self/status", "r"); + char line[256]; + long v = -1; + while (f && fgets(line, sizeof(line), f)) + if (!strncmp(line, key, strlen(key))) v = atol(line + strlen(key)); + if (f) fclose(f); + return v; +} + +static void request(char mode) { + int fd = open(ctl, O_WRONLY | O_TRUNC); + if (fd < 0 || write(fd, &mode, 1) != 1) exit(3); + close(fd); + kill(getpid(), SIGURG); +} + +static uint64_t sum(const unsigned char *p, size_t n) { + uint64_t s = 0; + for (size_t i = 0; i < n; i += 64) s = s * 31 + p[i]; + return s; +} + +/* Rewrites its pages with a round number and checks every page it wrote last round. */ +static void *writer(void *arg) { + unsigned char *mine = shared + (size_t)(uintptr_t)arg * (size / 4); + for (unsigned char round = 1; !stop; round++) { + for (size_t i = 0; i < size / 4; i += page) { + if (round > 1 && mine[i] != (unsigned char)(round - 1)) { + printf("corrupt\n"); + exit(4); + } + mine[i] = round; + } + } + return NULL; +} + +int main(int argc, char **argv) { + ctl = argv[1]; + size = strtoul(argv[2], NULL, 0) << 20; + int cycles = atoi(argv[3]); + page = (size_t)sysconf(_SC_PAGESIZE); + unsigned char *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + for (size_t i = 0; i < size; i++) data[i] = (unsigned char)(i * 7 + i / 4096); + uint64_t before = sum(data, size); + shared = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + memset(shared, 0, size); + pthread_t t[2]; + for (uintptr_t i = 0; i < 2; i++) pthread_create(&t[i], NULL, writer, (void *)i); + struct sigaction sa; + sigaction(SIGURG, NULL, &sa); + printf("handler %d\n", (sa.sa_flags & SA_SIGINFO) ? 1 : 0); + if (argc > 4) { + /* Read-only memory the process never pages out itself, swapped as the kernel would. */ + unsigned char *ro = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + memset(ro, 0x5a, size); + mprotect(ro, size, PROT_READ); + madvise(ro, size, 21); + printf("elsewhere %ld\n", status("VmSwap:")); + request('0'); + printf("back %ld\n", status("VmSwap:")); + } + for (int c = 0; c < cycles; c++) { + request('1'); + printf("out %ld\n", status("VmSwap:")); + request('0'); + printf("back %ld\n", status("VmSwap:")); + } + stop = 1; + for (int i = 0; i < 2; i++) pthread_join(t[i], NULL); + printf("intact %d\n", sum(data, size) == before); + return 0; +} +""" + + +def swap_total_kb(): + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("SwapTotal:"): + return int(line.split()[1]) + return 0 + + +@unittest.skipUnless(shutil.which("cc"), "needs a C compiler") +class ZramPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dir = Path(tempfile.mkdtemp()) + cls.ctl = cls.dir / "ctl" + build = cls.dir / "build" + build.mkdir() + (build / "program.c").write_text(PROGRAM) + cls.lib = build / "libblsession.so" + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-DZR_CTL=\"%s\"" % cls.ctl, + "-o", str(cls.lib), *map(str, sorted(PRELOAD.glob("*.c"))), "-ldl"], check=True) + cls.steam = cls.dir / "steam" + subprocess.run(["cc", "-O2", "-pthread", "-o", str(cls.steam), str(build / "program.c")], check=True) + cls.game = cls.dir / "game" + shutil.copy(cls.steam, cls.game) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.dir, True) + + def setUp(self): + self.ctl.write_text("0") + + def run_program(self, program=None, mb=48, cycles=1, *extra): + environment = dict(os.environ, LD_PRELOAD=str(self.lib)) + result = subprocess.run([str(program or self.steam), str(self.ctl), str(mb), str(cycles), *extra], env=environment, + capture_output=True, text=True, check=True, timeout=120) + out = {} + for line in result.stdout.splitlines(): + key, value = line.split() + out.setdefault(key, []).append(int(value)) + return out + + def test_only_the_client_takes_the_signal(self): + self.assertEqual([1], self.run_program()["handler"]) + self.assertEqual([0], self.run_program(self.game)["handler"]) + + def test_nothing_is_installed_without_the_control_file(self): + self.ctl.unlink() + try: + self.assertEqual([0], self.run_program(cycles=0)["handler"]) + finally: + self.ctl.write_text("0") + + @unittest.skipUnless(swap_total_kb() > 256 * 1024, "needs swap") + def test_pages_out_and_reads_back(self): + out = self.run_program(mb=48) + self.assertGreater(out["out"][0], 32 * 1024) + self.assertLess(out["back"][0], out["out"][0] // 4) + self.assertEqual([1], out["intact"]) + + @unittest.skipUnless(swap_total_kb() > 256 * 1024, "needs swap") + def test_reads_back_what_the_kernel_swapped_elsewhere(self): + out = self.run_program(None, 32, 0, "read-only") + self.assertGreater(out["elsewhere"][0], 16 * 1024) + self.assertLess(out["back"][0], out["elsewhere"][0] // 4) + + def test_memory_stays_intact_under_writers(self): + out = self.run_program(mb=32, cycles=6) + self.assertEqual([1], out["intact"]) + self.assertEqual(6, len(out["out"])) + + +if __name__ == "__main__": + unittest.main()