home-manager/firefox: Add memory control
CI / Check, build and cache nixfiles (push) Successful in 57m45s
Update docs / update (push) Successful in 1m17s

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.
This commit is contained in:
2026-08-13 12:24:45 +01:00
parent 0c6928f7ae
commit 82cbe67010
5 changed files with 457 additions and 3 deletions
+251
View File
@@ -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();
})();