From 82cbe67010d2dadbf98e6b0284d5f22e3de05ea0 Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan Date: Thu, 13 Aug 2026 12:24:45 +0100 Subject: [PATCH] home-manager/firefox: Add memory control Wrap Firefox with a privileged AutoConfig controller that unloads tabs under configurable MemAvailable hysteresis and exposes an on-demand reclaim command. Enable it by default for Linux GUI homes. --- home-manager/modules/gui/default.nix | 23 +- pkgs/default.nix | 1 + pkgs/firefox-memory-control/autoconfig.js | 251 ++++++++++++++++++ pkgs/firefox-memory-control/default.nix | 46 ++++ .../firefox-free-memory.py | 139 ++++++++++ 5 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 pkgs/firefox-memory-control/autoconfig.js create mode 100644 pkgs/firefox-memory-control/default.nix create mode 100644 pkgs/firefox-memory-control/firefox-free-memory.py diff --git a/home-manager/modules/gui/default.nix b/home-manager/modules/gui/default.nix index 58f2b8f..a0d34fc 100644 --- a/home-manager/modules/gui/default.nix +++ b/home-manager/modules/gui/default.nix @@ -1,6 +1,6 @@ { lib, pkgs', pkgs, config, ... }: let - inherit (lib) genAttrs mkIf mkMerge mkForce mapAttrs mkOptionDefault mkDefault; + inherit (lib) genAttrs mkIf mkMerge mkForce mapAttrs mkOptionDefault mkDefault optional; inherit (lib.my) mkOpt' mkBoolOpt'; inherit (lib.my.c) pubDomain; @@ -45,6 +45,9 @@ let chmod +x "$out"/bin/doomsaver ''; doomsaver = doomsaver' cfg.screensaver.brainrotTextCommand; + firefoxMemoryControl = pkgs.firefox-memory-control.override { + inherit (cfg.firefoxMemoryControl) lowAvailableMiB highAvailableMiB pollIntervalMs minInactiveMs; + }; in { options.my.gui = with lib.types; { @@ -52,12 +55,26 @@ in manageGraphical = mkBoolOpt' false "Configure the graphical session"; standalone = mkBoolOpt' false "Enable settings for fully Nix managed systems"; screensaver.brainrotTextCommand = mkOpt' (either path str) genLipsum "Command to generate brainrot text."; + firefoxMemoryControl = { + enable = mkBoolOpt' pkgs.stdenv.isLinux "Enable memory-pressure tab unloading in Firefox"; + lowAvailableMiB = mkOpt' ints.positive 2048 "Available memory threshold at which Firefox starts unloading tabs."; + highAvailableMiB = mkOpt' ints.positive 3072 "Available memory threshold at which Firefox stops unloading tabs."; + pollIntervalMs = mkOpt' ints.positive 1000 "Memory-pressure polling interval in milliseconds."; + minInactiveMs = mkOpt' ints.unsigned 300000 "Minimum tab inactivity before automatic unloading, in milliseconds."; + }; }; config = mkIf cfg.enable (mkMerge [ { + assertions = [ + { + assertion = cfg.firefoxMemoryControl.highAvailableMiB > cfg.firefoxMemoryControl.lowAvailableMiB; + message = "`my.gui.firefoxMemoryControl.highAvailableMiB` must exceed `lowAvailableMiB`."; + } + ]; + home = { - packages = with pkgs; [ + packages = (with pkgs; [ xdg-utils font.package @@ -99,7 +116,7 @@ in # --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_latest ]} # ''; # }) - ]; + ]) ++ optional cfg.firefoxMemoryControl.enable firefoxMemoryControl; }; programs = { diff --git a/pkgs/default.nix b/pkgs/default.nix index 7866d60..e930345 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -11,6 +11,7 @@ in chocolate-doom2xx = callPackage ./chocolate-doom2xx { }; windowtolayer = callPackage ./windowtolayer.nix { }; swaylock-plugin = callPackage ./swaylock-plugin.nix { }; + firefox-memory-control = callPackage ./firefox-memory-control { }; update-docs-assignments = pkgs.writeShellScriptBin "update-docs-assignments" '' exec ${pkgs.python3}/bin/python3 ${../ci/update-docs-assignments.py} "$@" diff --git a/pkgs/firefox-memory-control/autoconfig.js b/pkgs/firefox-memory-control/autoconfig.js new file mode 100644 index 0000000..4a818e5 --- /dev/null +++ b/pkgs/firefox-memory-control/autoconfig.js @@ -0,0 +1,251 @@ +// This file runs as privileged Firefox AutoConfig code. Keep it in the Nix store. +(() => { + "use strict"; + + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + Cu.importGlobalProperties(["IOUtils"]); + const Services = { + appinfo: Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime), + console: Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService), + env: Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment), + prefs: Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch), + }; + const { TabUnloader } = ChromeUtils.importESModule( + "moz-src:///browser/components/tabbrowser/TabUnloader.sys.mjs" + ); + + const PREFIX = "firefox.memoryControl."; + const MiB = 1024 * 1024; + const log = message => { + const line = `[firefox-memory-control] ${message}`; + Services.console.logStringMessage(line); + if (typeof dump === "function") { + dump(`${line}\n`); + } + }; + + const sleep = milliseconds => + new Promise(resolve => { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(resolve, milliseconds, Ci.nsITimer.TYPE_ONE_SHOT); + }); + + const prefInt = name => Services.prefs.getIntPref(PREFIX + name); + const prefBool = name => Services.prefs.getBoolPref(PREFIX + name); + + // procfs files report a size of zero, so IOUtils reads /proc/meminfo as empty. + // nsIScriptableInputStream also rejects reads larger than that reported size; + // nsIConverterInputStream reads until EOF without relying on it. + function availableMemory() { + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath("/proc/meminfo"); + const fileStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fileStream.init(file, 0x01, 0, 0); + const input = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance( + Ci.nsIConverterInputStream + ); + input.init(fileStream, "UTF-8", 0, 0); + const chunk = {}; + let meminfo = ""; + while (input.readString(4096, chunk)) { + meminfo += chunk.value; + } + input.close(); + + const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo); + if (!match) { + throw new Error("MemAvailable is absent from /proc/meminfo"); + } + return Number(match[1]) * 1024; + } + + async function unloadOne(minInactiveMs) { + const sorted = await TabUnloader.getSortedTabs(minInactiveMs); + const candidate = sorted.find(tab => TabUnloader.isDiscardable(tab)); + if (!candidate) { + return null; + } + + const estimatedBytes = candidate.memory || 0; + const unloaded = await TabUnloader.unloadLeastRecentlyUsedTab(minInactiveMs); + return unloaded ? { estimatedBytes } : null; + } + + function runtimePaths() { + const runtimeDir = Services.env.get("XDG_RUNTIME_DIR"); + if (!runtimeDir || !runtimeDir.startsWith("/")) { + throw new Error("XDG_RUNTIME_DIR is not an absolute path"); + } + + const root = `${runtimeDir}/firefox-memory-control`; + return { + root, + requests: `${root}/requests`, + processing: `${root}/processing`, + responses: `${root}/responses`, + }; + } + + async function ensureRuntimeDirectories(paths) { + for (const path of Object.values(paths)) { + await IOUtils.makeDirectory(path, { ignoreExisting: true, permissions: 0o700 }); + } + } + + async function claimRequest(paths) { + const children = await IOUtils.getChildren(paths.requests); + for (const requestPath of children.sort()) { + if (!requestPath.endsWith(".json")) { + continue; + } + + const leaf = requestPath.slice(requestPath.lastIndexOf("/") + 1); + const claimed = `${paths.processing}/${leaf}.${Services.appinfo.processID}`; + try { + await IOUtils.move(requestPath, claimed, { noOverwrite: true }); + return claimed; + } catch (error) { + // Another Firefox instance can win the atomic move. + } + } + return null; + } + + async function writeResponse(paths, id, response) { + const finalPath = `${paths.responses}/${id}.json`; + const temporaryPath = `${finalPath}.${Services.appinfo.processID}.tmp`; + await IOUtils.writeUTF8(temporaryPath, JSON.stringify(response)); + await IOUtils.move(temporaryPath, finalPath, { noOverwrite: true }); + } + + async function serviceRequest(paths, requestPath) { + let request; + try { + request = JSON.parse(await IOUtils.readUTF8(requestPath)); + if (!/^[0-9a-f-]{36}$/.test(request.id)) { + throw new Error("invalid request id"); + } + if (!Number.isSafeInteger(request.targetBytes) || request.targetBytes <= 0) { + throw new Error("targetBytes must be a positive integer"); + } + + const minInactiveMs = Number.isSafeInteger(request.minInactiveMs) + ? Math.max(0, request.minInactiveMs) + : 0; + const baseline = await availableMemory(); + let estimatedBytes = 0; + let observedBytes = 0; + let unloadedTabs = 0; + + while ( + estimatedBytes < request.targetBytes && + observedBytes < request.targetBytes && + unloadedTabs < 100 + ) { + const result = await unloadOne(minInactiveMs); + if (!result) { + break; + } + + unloadedTabs += 1; + estimatedBytes += result.estimatedBytes; + await sleep(400); + observedBytes = Math.max(0, (await availableMemory()) - baseline); + } + + const reachedTarget = + estimatedBytes >= request.targetBytes || observedBytes >= request.targetBytes; + await writeResponse(paths, request.id, { + id: request.id, + reachedTarget, + targetBytes: request.targetBytes, + unloadedTabs, + estimatedBytes, + observedBytes, + }); + } catch (error) { + log(`request failed: ${error}`); + if (request && /^[0-9a-f-]{36}$/.test(request.id)) { + await writeResponse(paths, request.id, { + id: request.id, + reachedTarget: false, + error: String(error), + }); + } + } finally { + await IOUtils.remove(requestPath, { ignoreAbsent: true }); + } + } + + const controller = { + busy: false, + underPressure: false, + timer: null, + paths: null, + + async tick() { + if (this.busy) { + return; + } + this.busy = true; + + try { + const request = await claimRequest(this.paths); + if (request) { + await serviceRequest(this.paths, request); + return; + } + + if (!prefBool("enabled")) { + this.underPressure = false; + return; + } + + const available = await availableMemory(); + const low = prefInt("lowAvailableMiB") * MiB; + const high = prefInt("highAvailableMiB") * MiB; + if (high <= low) { + throw new Error("highAvailableMiB must be greater than lowAvailableMiB"); + } + + if (!this.underPressure && available <= low) { + this.underPressure = true; + log(`memory pressure entered at ${Math.round(available / MiB)} MiB available`); + } else if (this.underPressure && available >= high) { + this.underPressure = false; + log(`memory pressure cleared at ${Math.round(available / MiB)} MiB available`); + } + + if (this.underPressure) { + await unloadOne(prefInt("minInactiveMs")); + } + } catch (error) { + log(`poll failed: ${error}`); + } finally { + this.busy = false; + } + }, + + async start() { + try { + this.paths = runtimePaths(); + await ensureRuntimeDirectories(this.paths); + const interval = Math.max(250, prefInt("pollIntervalMs")); + this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this.timer.initWithCallback( + () => this.tick(), + interval, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + log(`started; polling every ${interval} ms`); + await this.tick(); + } catch (error) { + log(`startup failed: ${error}`); + } + }, + }; + + controller.start(); +})(); diff --git a/pkgs/firefox-memory-control/default.nix b/pkgs/firefox-memory-control/default.nix new file mode 100644 index 0000000..b08e6bd --- /dev/null +++ b/pkgs/firefox-memory-control/default.nix @@ -0,0 +1,46 @@ +{ + lib, + firefox-unwrapped, + wrapFirefox, + writeText, + writeScriptBin, + symlinkJoin, + python3, + lowAvailableMiB ? 2048, + highAvailableMiB ? 3072, + pollIntervalMs ? 1000, + minInactiveMs ? 300000, +}: +let + autoConfig = writeText "firefox-memory-control.js" '' + defaultPref("firefox.memoryControl.enabled", true); + defaultPref("firefox.memoryControl.lowAvailableMiB", ${toString lowAvailableMiB}); + defaultPref("firefox.memoryControl.highAvailableMiB", ${toString highAvailableMiB}); + defaultPref("firefox.memoryControl.pollIntervalMs", ${toString pollIntervalMs}); + defaultPref("firefox.memoryControl.minInactiveMs", ${toString minInactiveMs}); + + ${builtins.readFile ./autoconfig.js} + ''; + + firefox = wrapFirefox firefox-unwrapped { + extraAutoConfig = '' + pref("general.config.sandbox_enabled", false); + ''; + extraPrefsFiles = [ autoConfig ]; + }; + + freeMemory = writeScriptBin "firefox-free-memory" '' + #!${python3}/bin/python3 + ${builtins.readFile ./firefox-free-memory.py} + ''; +in +symlinkJoin { + name = "firefox-memory-control-${firefox.version}"; + paths = [ firefox freeMemory ]; + + meta = firefox.meta // { + description = "Firefox with memory-pressure tab unloading and an on-demand reclaim utility"; + mainProgram = "firefox"; + platforms = lib.platforms.linux; + }; +} diff --git a/pkgs/firefox-memory-control/firefox-free-memory.py b/pkgs/firefox-memory-control/firefox-free-memory.py new file mode 100644 index 0000000..9fdf1c3 --- /dev/null +++ b/pkgs/firefox-memory-control/firefox-free-memory.py @@ -0,0 +1,139 @@ +import argparse +import json +import os +import re +import sys +import time +import uuid +from pathlib import Path + + +SIZE_RE = re.compile(r'^([0-9]+(?:\.[0-9]+)?)\s*([kmgt]?i?b?)?$', re.I) +DURATION_RE = re.compile(r'^([0-9]+(?:\.[0-9]+)?)\s*(ms|s|m|h)?$', re.I) + + +def parse_size(value): + match = SIZE_RE.match(value) + if not match: + raise argparse.ArgumentTypeError(f'invalid size: {value!r}') + + number = float(match.group(1)) + suffix = (match.group(2) or 'b').lower().removesuffix('b').removesuffix('i') + powers = {'': 0, 'k': 1, 'm': 2, 'g': 3, 't': 4} + size = round(number * 1024 ** powers[suffix]) + if size <= 0: + raise argparse.ArgumentTypeError('size must be greater than zero') + return size + + +def parse_duration(value): + match = DURATION_RE.match(value) + if not match: + raise argparse.ArgumentTypeError(f'invalid duration: {value!r}') + + number = float(match.group(1)) + suffix = (match.group(2) or 's').lower() + factors = {'ms': 1, 's': 1000, 'm': 60_000, 'h': 3_600_000} + return round(number * factors[suffix]) + + +def format_size(size): + for suffix in ('TiB', 'GiB', 'MiB', 'KiB'): + unit = 1024 ** {'KiB': 1, 'MiB': 2, 'GiB': 3, 'TiB': 4}[suffix] + if size >= unit: + return f'{size / unit:.2f} {suffix}' + return f'{size} B' + + +def runtime_root(): + runtime_dir = os.environ.get('XDG_RUNTIME_DIR') + if not runtime_dir or not os.path.isabs(runtime_dir): + raise RuntimeError('XDG_RUNTIME_DIR is not set to an absolute path') + return Path(runtime_dir) / 'firefox-memory-control' + + +def main(): + parser = argparse.ArgumentParser( + description='Ask a running memory-controlled Firefox to unload tabs' + ) + parser.add_argument('size', type=parse_size, help='desired reclaim amount, for example 2G') + parser.add_argument( + '--min-inactive', + type=parse_duration, + default=0, + metavar='DURATION', + help='only unload tabs inactive for this long (default: 0s)', + ) + parser.add_argument( + '--timeout', + type=float, + default=60, + metavar='SECONDS', + help='maximum time to wait for Firefox (default: 60)', + ) + args = parser.parse_args() + + root = runtime_root() + requests = root / 'requests' + responses = root / 'responses' + requests.mkdir(mode=0o700, parents=True, exist_ok=True) + responses.mkdir(mode=0o700, parents=True, exist_ok=True) + + request_id = str(uuid.uuid4()) + request_path = requests / f'{request_id}.json' + temporary_path = requests / f'.{request_id}.{os.getpid()}.tmp' + response_path = responses / f'{request_id}.json' + request = { + 'id': request_id, + 'targetBytes': args.size, + 'minInactiveMs': args.min_inactive, + } + + temporary_path.write_text(json.dumps(request), encoding='utf-8') + os.chmod(temporary_path, 0o600) + temporary_path.replace(request_path) + + deadline = time.monotonic() + args.timeout + try: + while time.monotonic() < deadline: + try: + response = json.loads(response_path.read_text(encoding='utf-8')) + break + except FileNotFoundError: + time.sleep(0.1) + else: + raise RuntimeError( + 'timed out waiting for Firefox; start Firefox from the ' + 'firefox-memory-control package' + ) + finally: + request_path.unlink(missing_ok=True) + + response_path.unlink(missing_ok=True) + if 'error' in response: + raise RuntimeError(response['error']) + + unloaded_tabs = response['unloadedTabs'] + estimated_bytes = response['estimatedBytes'] + observed_bytes = response['observedBytes'] + target_bytes = response['targetBytes'] + print( + f'unloaded {unloaded_tabs} tab(s); ' + f'Firefox estimated {format_size(estimated_bytes)} reclaimable; ' + f'MemAvailable increased by {format_size(observed_bytes)}' + ) + if not response['reachedTarget']: + print( + f'could not reach the requested {format_size(target_bytes)}', + file=sys.stderr, + ) + return 2 + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, RuntimeError) as error: + print(f'firefox-free-memory: {error}', file=sys.stderr) + sys.exit(1)