92855606a4
Firefox can exhaust swap while `MemAvailable` remains above the unload threshold. Track new `pswpout` pages so active swapping triggers tab unloading without treating stale swap occupancy as permanent pressure.
287 lines
9.0 KiB
JavaScript
287 lines
9.0 KiB
JavaScript
// 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 readProcFile(path) {
|
|
const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
|
|
file.initWithPath(path);
|
|
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 contents = "";
|
|
while (input.readString(4096, chunk)) {
|
|
contents += chunk.value;
|
|
}
|
|
input.close();
|
|
return contents;
|
|
}
|
|
|
|
function memoryInfo() {
|
|
const meminfo = readProcFile("/proc/meminfo");
|
|
|
|
const readKiB = name => {
|
|
const match = new RegExp(`^${name}:\\s+(\\d+)\\s+kB$`, "m").exec(meminfo);
|
|
if (!match) {
|
|
throw new Error(`${name} is absent from /proc/meminfo`);
|
|
}
|
|
return Number(match[1]) * 1024;
|
|
};
|
|
|
|
return {
|
|
available: readKiB("MemAvailable"),
|
|
};
|
|
}
|
|
|
|
const availableMemory = () => memoryInfo().available;
|
|
|
|
function swapOutPages() {
|
|
const match = /^pswpout\s+(\d+)$/m.exec(readProcFile("/proc/vmstat"));
|
|
if (!match) {
|
|
throw new Error("pswpout is absent from /proc/vmstat");
|
|
}
|
|
return Number(match[1]);
|
|
}
|
|
|
|
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,
|
|
previousSwapOutPages: 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 } = memoryInfo();
|
|
const currentSwapOutPages = swapOutPages();
|
|
// Swap usage persists after pressure passes, so react to new swap-outs instead.
|
|
const swappedOutPages =
|
|
this.previousSwapOutPages === null
|
|
? 0
|
|
: Math.max(0, currentSwapOutPages - this.previousSwapOutPages);
|
|
this.previousSwapOutPages = currentSwapOutPages;
|
|
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 || swappedOutPages > 0)) {
|
|
this.underPressure = true;
|
|
log(
|
|
`memory pressure entered at ${Math.round(available / MiB)} MiB available, ` +
|
|
`${swappedOutPages} pages swapped out since the previous poll`
|
|
);
|
|
} else if (this.underPressure && available >= high && swappedOutPages === 0) {
|
|
this.underPressure = false;
|
|
log(
|
|
`memory pressure cleared at ${Math.round(available / MiB)} MiB available, ` +
|
|
"no pages swapped out since the previous poll"
|
|
);
|
|
}
|
|
|
|
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();
|
|
})();
|