Add opt-in zRAM compression for the Steam client while a game runs (#442)

* Add opt-in zRAM compression for the Steam client while a game runs

A "zRAM compression" toggle under Steam > Settings > Session, below
"Keep downloads running". Off by default, kept once set, and disabled
on devices without swap or with a kernel older than 5.4.

When on, the session watches for Steam's reaper. 20 s into a game the
client and its web helpers page their private anonymous memory out to
the device's zram with MADV_PAGEOUT from inside their own process
(libblsession zram.c, SIGURG), again every 5 minutes, and read every
swapped page back once the game exits. No root is needed: only a
process itself may do this without CAP_SYS_NICE. With the toggle off
nothing is installed or started.

RedMagic NP06J, Palworld: Steam's resident memory fell from ~1.7 GB to
~1.1 GB with ~560 MB in zram, available memory during play rose by
~0.5-0.8 GB, and ~2 MB was left in zram after the read-back.

* Turn zRAM compression on by default below 12 GB of RAM

Until the toggle is changed, it follows the device: on when the kernel
supports it and the device has less than 12 GB of RAM, read from the
advertised memory size on Android 14+ and from total memory before that.
Turning it off is kept.

---------

Co-authored-by: xXJsonDeruloXx <danielhimebauch@gmail.com>
This commit is contained in:
MaxsTechReviewandxXJsonDeruloXx authored and GitHub committed 2026-10-13 13:33:19 -04:00
1 parent 7987b04529
commit 9a6d48c04b
10 files changed
+513

No files matched your search

@@ -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<Boolean?>(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
@@ -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<String> = runCatching { File("/proc/meminfo").readLines() }.getOrDefault(emptyList())): Long =
meminfo.firstOrNull { it.startsWith("SwapTotal:") }
?.substringAfter(':')?.trim()?.substringBefore(' ')?.toLongOrNull() ?: 0L
}
@@ -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 ────────────────────────────────────────────────────────────────────────
/**
@@ -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
@@ -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,
+3
View File
@@ -278,6 +278,9 @@
<string name="mode_suspend_steam_note">Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running.</string>
<string name="mode_background_downloads">Keep downloads running</string>
<string name="mode_background_downloads_hint">Delay background suspension while Steam is actively downloading.</string>
<string name="mode_zram">zRAM compression</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Unavailable: this device has no zram or swap, or its kernel is older than 5.4.</string>
<string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string>
<string name="mode_startup">Startup</string>
<string name="mode_steam_startup">Start Steam with DroidDeck</string>
@@ -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()))
}
}
@@ -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.
+176
View File
@@ -0,0 +1,176 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/uio.h>
#include <unistd.h>
/* 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);
}
+176
View File
@@ -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 <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
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()