[+] update oom_firefox
1. fix .pid attributes for dict; 2. reformat; 3. migrate into pr34 repo; 4. update Makefile for fetch, deploy;
This commit is contained in:
parent
a8d0f64419
commit
c568d8d9a7
2
Makefile
2
Makefile
@ -57,6 +57,7 @@ python_put_pr34:
|
|||||||
-U \
|
-U \
|
||||||
online.fxreader.pr34
|
online.fxreader.pr34
|
||||||
ln -sf $(INSTALL_ROOT)/env3/bin/online-fxreader-pr34-commands $(INSTALL_ROOT)/commands
|
ln -sf $(INSTALL_ROOT)/env3/bin/online-fxreader-pr34-commands $(INSTALL_ROOT)/commands
|
||||||
|
ln -sf $(INSTALL_ROOT)/env3/bin/oom_firefox $(INSTALL_ROOT)/oom_firefox
|
||||||
|
|
||||||
|
|
||||||
PYTHON_PROJECTS_NAMES ?= online.fxreader.pr34
|
PYTHON_PROJECTS_NAMES ?= online.fxreader.pr34
|
||||||
@ -122,7 +123,6 @@ dotfiles_fetch_platform:
|
|||||||
mkdir -p platform_dotfiles/$(PLATFORM)
|
mkdir -p platform_dotfiles/$(PLATFORM)
|
||||||
tar -cvf - \
|
tar -cvf - \
|
||||||
/etc/udev/rules.d/ \
|
/etc/udev/rules.d/ \
|
||||||
~/.local/bin/oom_firefox \
|
|
||||||
~/.local/bin/systemd_gtk \
|
~/.local/bin/systemd_gtk \
|
||||||
~/.local/bin/gnome-shortcuts-macbook-air \
|
~/.local/bin/gnome-shortcuts-macbook-air \
|
||||||
/usr/local/bin \
|
/usr/local/bin \
|
||||||
|
|||||||
@ -1,441 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import psutil
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from prompt_toolkit.application import Application
|
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
|
||||||
from prompt_toolkit.layout import Layout, HSplit, FloatContainer, Float
|
|
||||||
from prompt_toolkit.layout.containers import Window
|
|
||||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
||||||
from prompt_toolkit.widgets import TextArea, Frame, Dialog, Button, Label
|
|
||||||
from prompt_toolkit.styles import Style
|
|
||||||
|
|
||||||
from typing import (TypedDict,)
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
__version__ = "0.6.1"
|
|
||||||
__created__ = "2025-11-21"
|
|
||||||
|
|
||||||
# — Helper for cgroup / slice matching —
|
|
||||||
|
|
||||||
def get_cgroup_path(pid):
|
|
||||||
try:
|
|
||||||
with open(f"/proc/{pid}/cgroup", "r") as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split(":", 2)
|
|
||||||
if len(parts) == 3:
|
|
||||||
return parts[2]
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def slice_matches(cpath, target_slice):
|
|
||||||
if not cpath or not target_slice:
|
|
||||||
return False
|
|
||||||
comps = cpath.strip("/").split("/")
|
|
||||||
tgt = target_slice.lower()
|
|
||||||
for comp in comps:
|
|
||||||
name = comp.lower()
|
|
||||||
if name.endswith(".slice"):
|
|
||||||
name = name[:-6]
|
|
||||||
if tgt == name:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# — Memory‑management logic —
|
|
||||||
|
|
||||||
class get_firefox_procs_ps_t:
|
|
||||||
class res_t:
|
|
||||||
class entry_t(TypedDict):
|
|
||||||
rss: int
|
|
||||||
pid: int
|
|
||||||
ppid: int
|
|
||||||
cgroup: str
|
|
||||||
cmd: str
|
|
||||||
|
|
||||||
def get_firefox_procs_ps(slice_name=None) -> list[get_firefox_procs_ps_t.res_t.entry_t]:
|
|
||||||
entries : dict[int, dict[str, Any]] = dict()
|
|
||||||
|
|
||||||
for regex, columns in [
|
|
||||||
(
|
|
||||||
re.compile(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$'),
|
|
||||||
OrderedDict(
|
|
||||||
pid=lambda x: int(x[1]),
|
|
||||||
rss=lambda x: int(x[2]) * 1024,
|
|
||||||
ppid=lambda x: int(x[3]),
|
|
||||||
cmd=lambda x: x[4],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
re.compile(r'^\s*(\d+)\s+(.*)$'),
|
|
||||||
OrderedDict(
|
|
||||||
pid=lambda x: int(x[1]),
|
|
||||||
cgroup=lambda x: x[2],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]:
|
|
||||||
lines = subprocess.check_output([
|
|
||||||
'ps', '-ax', '-o', ','.join(columns.keys()),
|
|
||||||
]).decode('utf-8').splitlines()[1:]
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
r = re.compile(regex)
|
|
||||||
# print([r, line])
|
|
||||||
match = r.match(line)
|
|
||||||
|
|
||||||
assert match
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
k : v(match)
|
|
||||||
for k, v in columns.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
if not entry['pid'] in entries:
|
|
||||||
entries[entry['pid']] = dict()
|
|
||||||
|
|
||||||
entries[entry['pid']].update(entry)
|
|
||||||
|
|
||||||
filtered_entries : list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for entry in entries.values():
|
|
||||||
if not 'cgroup' in entry or not 'rss' in entry:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not slice_name is None:
|
|
||||||
if not slice_name in entry['cgroup']:
|
|
||||||
continue
|
|
||||||
filtered_entries.append(entry)
|
|
||||||
|
|
||||||
return filtered_entries
|
|
||||||
|
|
||||||
def get_firefox_procs(slice_name=None):
|
|
||||||
procs = []
|
|
||||||
for p in psutil.process_iter(['pid', 'name', 'cmdline', 'memory_info']):
|
|
||||||
try:
|
|
||||||
name = p.info['name']
|
|
||||||
cmd = p.info['cmdline']
|
|
||||||
if not cmd:
|
|
||||||
continue
|
|
||||||
if 'firefox' not in name and not (cmd and 'firefox' in cmd[0]):
|
|
||||||
continue
|
|
||||||
if slice_name:
|
|
||||||
cpath = get_cgroup_path(p.pid)
|
|
||||||
if not slice_matches(cpath, slice_name):
|
|
||||||
continue
|
|
||||||
procs.append(p)
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
||||||
continue
|
|
||||||
return procs
|
|
||||||
|
|
||||||
def total_rss_mb(procs: list['get_firefox_procs_ps_t.res_t.entry_t']):
|
|
||||||
total = 0
|
|
||||||
for p in procs:
|
|
||||||
try:
|
|
||||||
# total += p.memory_info().rss
|
|
||||||
total += p['rss']
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
pass
|
|
||||||
return total / (1024 * 1024)
|
|
||||||
|
|
||||||
def is_main_firefox(p):
|
|
||||||
try:
|
|
||||||
# for arg in p.cmdline():
|
|
||||||
#for arg in p['cmd'].split():
|
|
||||||
# if "contentproc" in arg:
|
|
||||||
# return False
|
|
||||||
if 'contentproc' in p['cmd']:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
return False
|
|
||||||
|
|
||||||
def kill_prioritized(
|
|
||||||
procs: list['get_firefox_procs_ps_t.res_t.entry_t'],
|
|
||||||
to_free_mb,
|
|
||||||
low_priority_pids
|
|
||||||
):
|
|
||||||
candidates = []
|
|
||||||
for p in procs:
|
|
||||||
if is_main_firefox(p):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
# rss_mb = p.memory_info().rss / (1024 * 1024)
|
|
||||||
rss_mb = p['rss'] / (1024 * 1024)
|
|
||||||
candidates.append((p, rss_mb))
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
continue
|
|
||||||
|
|
||||||
candidates.sort(key=lambda x: ((x[0].pid in low_priority_pids), -x[1]))
|
|
||||||
|
|
||||||
freed = 0.0
|
|
||||||
killed = []
|
|
||||||
for p, rss in candidates:
|
|
||||||
if freed >= to_free_mb:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
os.kill(p.pid, signal.SIGTERM)
|
|
||||||
killed.append(p.pid)
|
|
||||||
freed += rss
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error killing pid {p.pid}")
|
|
||||||
# print(f"Error killing pid {p.pid}: {e}", file=sys.stderr)
|
|
||||||
return killed, freed
|
|
||||||
|
|
||||||
# — systemd-run logic —
|
|
||||||
|
|
||||||
def launch_firefox_with_limits(base_cmd, memory_high, swap_max, extra_args, unit_name):
|
|
||||||
cmd = [
|
|
||||||
"systemd-run",
|
|
||||||
"--user",
|
|
||||||
"--scope",
|
|
||||||
"-p", f"MemoryHigh={int(memory_high)}M",
|
|
||||||
]
|
|
||||||
if swap_max is not None:
|
|
||||||
cmd += ["-p", f"MemorySwapMax={int(swap_max)}M"]
|
|
||||||
if unit_name:
|
|
||||||
cmd += ["--unit", unit_name]
|
|
||||||
|
|
||||||
cmd += base_cmd
|
|
||||||
cmd += extra_args
|
|
||||||
|
|
||||||
devnull = subprocess.DEVNULL
|
|
||||||
proc = subprocess.Popen(cmd, stdin=devnull, stdout=devnull, stderr=devnull)
|
|
||||||
print("Launched Firefox via systemd-run, PID:", proc.pid, file=sys.stderr)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
# — Main + TUI + Monitoring —
|
|
||||||
|
|
||||||
def main():
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Firefox memory manager with slice + graceful shutdown")
|
|
||||||
parser.add_argument("--max-mb", type=float, required=True,
|
|
||||||
help="Memory threshold in MB (used for killing logic & MemoryHigh)")
|
|
||||||
parser.add_argument("--kill-percent", type=float, default=70.0,
|
|
||||||
help="If over max, kill until usage ≤ this percent of max")
|
|
||||||
parser.add_argument("--swap-max-mb", type=float, default=None,
|
|
||||||
help="MemorySwapMax (MB) for the systemd scope")
|
|
||||||
parser.add_argument("--interval", type=float, default=1.0,
|
|
||||||
help="Monitoring interval in seconds")
|
|
||||||
parser.add_argument("--unit-name", type=str, default="firefox-limited",
|
|
||||||
help="Name for systemd transient unit")
|
|
||||||
parser.add_argument("--firefox-extra", action="append", default=[],
|
|
||||||
help="Extra CLI args to pass to Firefox (can repeat)")
|
|
||||||
parser.add_argument("firefox_cmd", nargs=argparse.REMAINDER,
|
|
||||||
help="Firefox command + args (if launching it)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
low_priority_pids = set()
|
|
||||||
body = TextArea(focusable=False, scrollbar=True)
|
|
||||||
|
|
||||||
terminate_flag = threading.Event()
|
|
||||||
|
|
||||||
lock = threading.Lock()
|
|
||||||
|
|
||||||
firefox_proc = None
|
|
||||||
|
|
||||||
def terminate():
|
|
||||||
terminate_flag.set()
|
|
||||||
app.exit()
|
|
||||||
|
|
||||||
def stop():
|
|
||||||
with lock:
|
|
||||||
if firefox_proc:
|
|
||||||
try:
|
|
||||||
firefox_proc.terminate()
|
|
||||||
firefox_proc.wait(timeout=5)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
try:
|
|
||||||
firefox_proc.kill()
|
|
||||||
except Exception:
|
|
||||||
logger.exception('')
|
|
||||||
pass
|
|
||||||
# app.exit()
|
|
||||||
|
|
||||||
# signal.signal(signal.SIGINT, lambda s, f: terminate())
|
|
||||||
# signal.signal(signal.SIGTERM, lambda s, f: terminate())
|
|
||||||
|
|
||||||
def refresh_body():
|
|
||||||
nonlocal firefox_proc
|
|
||||||
|
|
||||||
with lock:
|
|
||||||
procs = get_firefox_procs_ps(slice_name=args.unit_name)
|
|
||||||
total = total_rss_mb(procs)
|
|
||||||
limit = args.max_mb
|
|
||||||
kill_to = args.kill_percent / 100.0 * limit
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
f"Firefox RSS (slice={args.unit_name}): {total:.1f} MB",
|
|
||||||
f"Threshold (max): {limit:.1f} MB",
|
|
||||||
f"Kill‑to target: {kill_to:.1f} MB ({args.kill_percent}%)",
|
|
||||||
f"Low‑priority PIDs: {sorted(low_priority_pids)}"
|
|
||||||
]
|
|
||||||
|
|
||||||
if total > limit:
|
|
||||||
to_free = total - kill_to
|
|
||||||
killed, freed = kill_prioritized(procs, to_free, low_priority_pids)
|
|
||||||
lines.append(f"Killed: {killed}")
|
|
||||||
lines.append(f"Freed ≈ {freed:.1f} MB")
|
|
||||||
else:
|
|
||||||
lines.append("Within limit — no kill")
|
|
||||||
|
|
||||||
if firefox_proc and firefox_proc.poll() is not None:
|
|
||||||
print("Firefox died — restarting …", file=sys.stderr)
|
|
||||||
firefox_proc = launch_firefox_with_limits(
|
|
||||||
args.firefox_cmd,
|
|
||||||
memory_high=args.max_mb,
|
|
||||||
swap_max=args.swap_max_mb,
|
|
||||||
extra_args=args.firefox_extra,
|
|
||||||
unit_name=args.unit_name
|
|
||||||
)
|
|
||||||
|
|
||||||
body.text = "\n".join(lines)
|
|
||||||
|
|
||||||
dialog_float = [None]
|
|
||||||
root_floats = []
|
|
||||||
|
|
||||||
def open_pid_dialog():
|
|
||||||
ta = TextArea(text="", multiline=True, scrollbar=True)
|
|
||||||
|
|
||||||
def on_ok():
|
|
||||||
txt = ta.text
|
|
||||||
for m in re.finditer(r"\((\d+)\)", txt):
|
|
||||||
low_priority_pids.add(int(m.group(1)))
|
|
||||||
close_dialog()
|
|
||||||
refresh_body()
|
|
||||||
|
|
||||||
def on_cancel():
|
|
||||||
close_dialog()
|
|
||||||
|
|
||||||
dialog = Dialog(
|
|
||||||
title="Enter low‑priority PIDs",
|
|
||||||
body=ta,
|
|
||||||
buttons=[Button(text="OK", handler=on_ok), Button(text="Cancel", handler=on_cancel)],
|
|
||||||
width=60,
|
|
||||||
modal=True
|
|
||||||
)
|
|
||||||
f = Float(content=dialog, left=2, top=2)
|
|
||||||
dialog_float[0] = f
|
|
||||||
root_floats.append(f)
|
|
||||||
app.layout.focus(ta)
|
|
||||||
|
|
||||||
def open_message(title, message):
|
|
||||||
def on_close():
|
|
||||||
close_dialog()
|
|
||||||
|
|
||||||
dialog = Dialog(
|
|
||||||
title=title,
|
|
||||||
body=Label(text=message),
|
|
||||||
buttons=[Button(text="Close", handler=on_close)],
|
|
||||||
width=50,
|
|
||||||
modal=True
|
|
||||||
)
|
|
||||||
f = Float(content=dialog, left=4, top=4)
|
|
||||||
dialog_float[0] = f
|
|
||||||
root_floats.append(f)
|
|
||||||
app.layout.focus(dialog)
|
|
||||||
|
|
||||||
def close_dialog():
|
|
||||||
f = dialog_float[0]
|
|
||||||
if f in root_floats:
|
|
||||||
root_floats.remove(f)
|
|
||||||
dialog_float[0] = None
|
|
||||||
app.layout.focus(body)
|
|
||||||
|
|
||||||
kb = KeyBindings()
|
|
||||||
|
|
||||||
@kb.add("q")
|
|
||||||
def _(event):
|
|
||||||
terminate()
|
|
||||||
|
|
||||||
@kb.add("m")
|
|
||||||
def _(event):
|
|
||||||
open_pid_dialog()
|
|
||||||
|
|
||||||
@kb.add("h")
|
|
||||||
def _(event):
|
|
||||||
open_message("Help", "Keys: m=add PIDs, s=settings, a=about, q=quit")
|
|
||||||
|
|
||||||
@kb.add("s")
|
|
||||||
def _(event):
|
|
||||||
open_message("Settings",
|
|
||||||
f"max_mb = {args.max_mb}\n"
|
|
||||||
f"kill_percent = {args.kill_percent}\n"
|
|
||||||
f"slice = {args.unit_name}\n"
|
|
||||||
f"swap_max_mb = {args.swap_max_mb}\n"
|
|
||||||
f"extra firefox args = {args.firefox_extra}")
|
|
||||||
|
|
||||||
@kb.add("a")
|
|
||||||
def _(event):
|
|
||||||
open_message("About", f"Version {__version__}\nCreated {__created__}")
|
|
||||||
|
|
||||||
root = FloatContainer(
|
|
||||||
content=HSplit([
|
|
||||||
Frame(body, title="Firefox Memory Manager"),
|
|
||||||
Window(height=1, content=FormattedTextControl("q=quit, m=PID, h=help, s=setting, a=about"))
|
|
||||||
]),
|
|
||||||
floats=root_floats,
|
|
||||||
modal=True
|
|
||||||
)
|
|
||||||
|
|
||||||
style = Style.from_dict({
|
|
||||||
"frame.border": "ansicyan",
|
|
||||||
"dialog.body": "bg:#444444",
|
|
||||||
"dialog": "bg:#888888",
|
|
||||||
})
|
|
||||||
|
|
||||||
app = Application(
|
|
||||||
layout=Layout(root),
|
|
||||||
key_bindings=kb,
|
|
||||||
style=style,
|
|
||||||
full_screen=True,
|
|
||||||
refresh_interval=args.interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.firefox_cmd:
|
|
||||||
firefox_proc = launch_firefox_with_limits(
|
|
||||||
args.firefox_cmd,
|
|
||||||
memory_high=args.max_mb,
|
|
||||||
swap_max=args.swap_max_mb, # **fixed here**
|
|
||||||
extra_args=args.firefox_extra,
|
|
||||||
unit_name=args.unit_name
|
|
||||||
)
|
|
||||||
|
|
||||||
def monitor_loop():
|
|
||||||
nonlocal firefox_proc
|
|
||||||
while not terminate_flag.is_set():
|
|
||||||
refresh_body()
|
|
||||||
|
|
||||||
time.sleep(args.interval)
|
|
||||||
|
|
||||||
# stop()
|
|
||||||
|
|
||||||
terminate_flag = threading.Event()
|
|
||||||
t = threading.Thread(target=monitor_loop, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
# refresh_body()
|
|
||||||
app.run(handle_sigint=True) # from prompt‑toolkit API :contentReference[oaicite:0]{index=0}
|
|
||||||
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
stop()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -5,7 +5,7 @@ project(
|
|||||||
).stdout().strip('\n'),
|
).stdout().strip('\n'),
|
||||||
# 'online.fxreader.uv',
|
# 'online.fxreader.uv',
|
||||||
# ['c', 'cpp'],
|
# ['c', 'cpp'],
|
||||||
version: '0.1.5.41',
|
version: '0.1.5.42',
|
||||||
# default_options: [
|
# default_options: [
|
||||||
# 'cpp_std=c++23',
|
# 'cpp_std=c++23',
|
||||||
# # 'prefer_static=true',
|
# # 'prefer_static=true',
|
||||||
|
|||||||
468
python/online/fxreader/pr34/oom_firefox.py
Normal file
468
python/online/fxreader/pr34/oom_firefox.py
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import psutil
|
||||||
|
import pathlib
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from prompt_toolkit.application import Application
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.layout import Layout, HSplit, FloatContainer, Float
|
||||||
|
from prompt_toolkit.layout.containers import Window
|
||||||
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||||
|
from prompt_toolkit.widgets import TextArea, Frame, Dialog, Button, Label
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
TypedDict,
|
||||||
|
Any,
|
||||||
|
)
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__version__ = '0.6.1'
|
||||||
|
__created__ = '2025-11-21'
|
||||||
|
|
||||||
|
# — Helper for cgroup / slice matching —
|
||||||
|
|
||||||
|
|
||||||
|
def get_cgroup_path(pid):
|
||||||
|
try:
|
||||||
|
with open(f'/proc/{pid}/cgroup', 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split(':', 2)
|
||||||
|
if len(parts) == 3:
|
||||||
|
return parts[2]
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def slice_matches(cpath, target_slice):
|
||||||
|
if not cpath or not target_slice:
|
||||||
|
return False
|
||||||
|
comps = cpath.strip('/').split('/')
|
||||||
|
tgt = target_slice.lower()
|
||||||
|
for comp in comps:
|
||||||
|
name = comp.lower()
|
||||||
|
if name.endswith('.slice'):
|
||||||
|
name = name[:-6]
|
||||||
|
if tgt == name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# — Memory‑management logic —
|
||||||
|
|
||||||
|
|
||||||
|
class get_firefox_procs_ps_t:
|
||||||
|
class res_t:
|
||||||
|
class entry_t(TypedDict):
|
||||||
|
rss: int
|
||||||
|
pid: int
|
||||||
|
ppid: int
|
||||||
|
cgroup: str
|
||||||
|
cmd: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_firefox_procs_ps(slice_name=None) -> list[get_firefox_procs_ps_t.res_t.entry_t]:
|
||||||
|
entries: dict[int, dict[str, Any]] = dict()
|
||||||
|
|
||||||
|
for regex, columns in [
|
||||||
|
(
|
||||||
|
re.compile(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$'),
|
||||||
|
OrderedDict(
|
||||||
|
pid=lambda x: int(x[1]),
|
||||||
|
rss=lambda x: int(x[2]) * 1024,
|
||||||
|
ppid=lambda x: int(x[3]),
|
||||||
|
cmd=lambda x: x[4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(r'^\s*(\d+)\s+(.*)$'),
|
||||||
|
OrderedDict(
|
||||||
|
pid=lambda x: int(x[1]),
|
||||||
|
cgroup=lambda x: x[2],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]:
|
||||||
|
lines = (
|
||||||
|
subprocess.check_output(
|
||||||
|
[
|
||||||
|
'ps',
|
||||||
|
'-ax',
|
||||||
|
'-o',
|
||||||
|
','.join(columns.keys()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.decode('utf-8')
|
||||||
|
.splitlines()[1:]
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
r = re.compile(regex)
|
||||||
|
# print([r, line])
|
||||||
|
match = r.match(line)
|
||||||
|
|
||||||
|
assert match
|
||||||
|
|
||||||
|
entry = {k: v(match) for k, v in columns.items()}
|
||||||
|
|
||||||
|
if not entry['pid'] in entries:
|
||||||
|
entries[entry['pid']] = dict()
|
||||||
|
|
||||||
|
entries[entry['pid']].update(entry)
|
||||||
|
|
||||||
|
filtered_entries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in entries.values():
|
||||||
|
if not 'cgroup' in entry or not 'rss' in entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not slice_name is None:
|
||||||
|
if not slice_name in entry['cgroup']:
|
||||||
|
continue
|
||||||
|
filtered_entries.append(entry)
|
||||||
|
|
||||||
|
return filtered_entries
|
||||||
|
|
||||||
|
|
||||||
|
def get_firefox_procs(slice_name=None):
|
||||||
|
procs = []
|
||||||
|
for p in psutil.process_iter(['pid', 'name', 'cmdline', 'memory_info']):
|
||||||
|
try:
|
||||||
|
name = p.info['name']
|
||||||
|
cmd = p.info['cmdline']
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
if 'firefox' not in name and not (cmd and 'firefox' in cmd[0]):
|
||||||
|
continue
|
||||||
|
if slice_name:
|
||||||
|
cpath = get_cgroup_path(p.pid)
|
||||||
|
if not slice_matches(cpath, slice_name):
|
||||||
|
continue
|
||||||
|
procs.append(p)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
return procs
|
||||||
|
|
||||||
|
|
||||||
|
def total_rss_mb(procs: list['get_firefox_procs_ps_t.res_t.entry_t']):
|
||||||
|
total = 0
|
||||||
|
for p in procs:
|
||||||
|
try:
|
||||||
|
# total += p.memory_info().rss
|
||||||
|
total += p['rss']
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
pass
|
||||||
|
return total / (1024 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def is_main_firefox(p):
|
||||||
|
try:
|
||||||
|
# for arg in p.cmdline():
|
||||||
|
# for arg in p['cmd'].split():
|
||||||
|
# if "contentproc" in arg:
|
||||||
|
# return False
|
||||||
|
if 'contentproc' in p['cmd']:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def kill_prioritized(procs: list['get_firefox_procs_ps_t.res_t.entry_t'], to_free_mb, low_priority_pids):
|
||||||
|
candidates = []
|
||||||
|
for p in procs:
|
||||||
|
if is_main_firefox(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# rss_mb = p.memory_info().rss / (1024 * 1024)
|
||||||
|
rss_mb = p['rss'] / (1024 * 1024)
|
||||||
|
candidates.append((p, rss_mb))
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates.sort(key=lambda x: ((x[0]['pid'] in low_priority_pids), -x[1]))
|
||||||
|
|
||||||
|
freed = 0.0
|
||||||
|
killed = []
|
||||||
|
for p, rss in candidates:
|
||||||
|
if freed >= to_free_mb:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
dict(
|
||||||
|
p=p,
|
||||||
|
action='kill',
|
||||||
|
msg='started',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(p['pid'], signal.SIGTERM)
|
||||||
|
killed.append(p['pid'])
|
||||||
|
freed += rss
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f'Error killing pid {p["pid"]}')
|
||||||
|
# print(f"Error killing pid {p.pid}: {e}", file=sys.stderr)
|
||||||
|
return killed, freed
|
||||||
|
|
||||||
|
|
||||||
|
# — systemd-run logic —
|
||||||
|
|
||||||
|
|
||||||
|
def launch_firefox_with_limits(base_cmd, memory_high, swap_max, extra_args, unit_name):
|
||||||
|
cmd = [
|
||||||
|
'systemd-run',
|
||||||
|
'--user',
|
||||||
|
'--scope',
|
||||||
|
'-p',
|
||||||
|
f'MemoryHigh={int(memory_high)}M',
|
||||||
|
]
|
||||||
|
if swap_max is not None:
|
||||||
|
cmd += ['-p', f'MemorySwapMax={int(swap_max)}M']
|
||||||
|
if unit_name:
|
||||||
|
cmd += ['--unit', unit_name]
|
||||||
|
|
||||||
|
cmd += base_cmd
|
||||||
|
cmd += extra_args
|
||||||
|
|
||||||
|
devnull = subprocess.DEVNULL
|
||||||
|
proc = subprocess.Popen(cmd, stdin=devnull, stdout=devnull, stderr=devnull)
|
||||||
|
print('Launched Firefox via systemd-run, PID:', proc.pid, file=sys.stderr)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
# — Main + TUI + Monitoring —
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(pathlib.Path('~/.cache/oom_firefox/').expanduser(), exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
handlers=[
|
||||||
|
logging.handlers.RotatingFileHandler(
|
||||||
|
pathlib.Path('~/.cache/oom_firefox/log').expanduser(),
|
||||||
|
maxBytes=128 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Firefox memory manager with slice + graceful shutdown')
|
||||||
|
parser.add_argument('--max-mb', type=float, required=True, help='Memory threshold in MB (used for killing logic & MemoryHigh)')
|
||||||
|
parser.add_argument('--kill-percent', type=float, default=70.0, help='If over max, kill until usage ≤ this percent of max')
|
||||||
|
parser.add_argument('--swap-max-mb', type=float, default=None, help='MemorySwapMax (MB) for the systemd scope')
|
||||||
|
parser.add_argument('--interval', type=float, default=1.0, help='Monitoring interval in seconds')
|
||||||
|
parser.add_argument('--unit-name', type=str, default='firefox-limited', help='Name for systemd transient unit')
|
||||||
|
parser.add_argument('--firefox-extra', action='append', default=[], help='Extra CLI args to pass to Firefox (can repeat)')
|
||||||
|
parser.add_argument('firefox_cmd', nargs=argparse.REMAINDER, help='Firefox command + args (if launching it)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
low_priority_pids = set()
|
||||||
|
body = TextArea(focusable=False, scrollbar=True)
|
||||||
|
|
||||||
|
terminate_flag = threading.Event()
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
firefox_proc = None
|
||||||
|
|
||||||
|
def terminate():
|
||||||
|
terminate_flag.set()
|
||||||
|
app.exit()
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
with lock:
|
||||||
|
if firefox_proc:
|
||||||
|
try:
|
||||||
|
firefox_proc.terminate()
|
||||||
|
firefox_proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
try:
|
||||||
|
firefox_proc.kill()
|
||||||
|
except Exception:
|
||||||
|
logger.exception('')
|
||||||
|
pass
|
||||||
|
# app.exit()
|
||||||
|
|
||||||
|
# signal.signal(signal.SIGINT, lambda s, f: terminate())
|
||||||
|
# signal.signal(signal.SIGTERM, lambda s, f: terminate())
|
||||||
|
|
||||||
|
def refresh_body():
|
||||||
|
nonlocal firefox_proc
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
procs = get_firefox_procs_ps(slice_name=args.unit_name)
|
||||||
|
total = total_rss_mb(procs)
|
||||||
|
limit = args.max_mb
|
||||||
|
kill_to = args.kill_percent / 100.0 * limit
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'Firefox RSS (slice={args.unit_name}): {total:.1f} MB',
|
||||||
|
f'Threshold (max): {limit:.1f} MB',
|
||||||
|
f'Kill‑to target: {kill_to:.1f} MB ({args.kill_percent}%)',
|
||||||
|
f'Low‑priority PIDs: {sorted(low_priority_pids)}',
|
||||||
|
]
|
||||||
|
|
||||||
|
if total > limit:
|
||||||
|
to_free = total - kill_to
|
||||||
|
killed, freed = kill_prioritized(procs, to_free, low_priority_pids)
|
||||||
|
lines.append(f'Killed: {killed}')
|
||||||
|
lines.append(f'Freed ≈ {freed:.1f} MB')
|
||||||
|
else:
|
||||||
|
lines.append('Within limit — no kill')
|
||||||
|
|
||||||
|
if firefox_proc and firefox_proc.poll() is not None:
|
||||||
|
print('Firefox died — restarting …', file=sys.stderr)
|
||||||
|
firefox_proc = launch_firefox_with_limits(
|
||||||
|
args.firefox_cmd, memory_high=args.max_mb, swap_max=args.swap_max_mb, extra_args=args.firefox_extra, unit_name=args.unit_name
|
||||||
|
)
|
||||||
|
|
||||||
|
body.text = '\n'.join(lines)
|
||||||
|
|
||||||
|
dialog_float = [None]
|
||||||
|
root_floats = []
|
||||||
|
|
||||||
|
def open_pid_dialog():
|
||||||
|
ta = TextArea(text='', multiline=True, scrollbar=True)
|
||||||
|
|
||||||
|
def on_ok():
|
||||||
|
txt = ta.text
|
||||||
|
for m in re.finditer(r'\((\d+)\)', txt):
|
||||||
|
low_priority_pids.add(int(m.group(1)))
|
||||||
|
close_dialog()
|
||||||
|
refresh_body()
|
||||||
|
|
||||||
|
def on_cancel():
|
||||||
|
close_dialog()
|
||||||
|
|
||||||
|
dialog = Dialog(
|
||||||
|
title='Enter low‑priority PIDs', body=ta, buttons=[Button(text='OK', handler=on_ok), Button(text='Cancel', handler=on_cancel)], width=60, modal=True
|
||||||
|
)
|
||||||
|
f = Float(content=dialog, left=2, top=2)
|
||||||
|
dialog_float[0] = f
|
||||||
|
root_floats.append(f)
|
||||||
|
app.layout.focus(ta)
|
||||||
|
|
||||||
|
def open_message(title, message):
|
||||||
|
def on_close():
|
||||||
|
close_dialog()
|
||||||
|
|
||||||
|
dialog = Dialog(title=title, body=Label(text=message), buttons=[Button(text='Close', handler=on_close)], width=50, modal=True)
|
||||||
|
f = Float(content=dialog, left=4, top=4)
|
||||||
|
dialog_float[0] = f
|
||||||
|
root_floats.append(f)
|
||||||
|
app.layout.focus(dialog)
|
||||||
|
|
||||||
|
def close_dialog():
|
||||||
|
f = dialog_float[0]
|
||||||
|
if f in root_floats:
|
||||||
|
root_floats.remove(f)
|
||||||
|
dialog_float[0] = None
|
||||||
|
app.layout.focus(body)
|
||||||
|
|
||||||
|
kb = KeyBindings()
|
||||||
|
|
||||||
|
@kb.add('q')
|
||||||
|
def _(event):
|
||||||
|
terminate()
|
||||||
|
|
||||||
|
@kb.add('m')
|
||||||
|
def _(event):
|
||||||
|
open_pid_dialog()
|
||||||
|
|
||||||
|
@kb.add('h')
|
||||||
|
def _(event):
|
||||||
|
open_message('Help', 'Keys: m=add PIDs, s=settings, a=about, q=quit')
|
||||||
|
|
||||||
|
@kb.add('s')
|
||||||
|
def _(event):
|
||||||
|
open_message(
|
||||||
|
'Settings',
|
||||||
|
f'max_mb = {args.max_mb}\n'
|
||||||
|
f'kill_percent = {args.kill_percent}\n'
|
||||||
|
f'slice = {args.unit_name}\n'
|
||||||
|
f'swap_max_mb = {args.swap_max_mb}\n'
|
||||||
|
f'extra firefox args = {args.firefox_extra}',
|
||||||
|
)
|
||||||
|
|
||||||
|
@kb.add('a')
|
||||||
|
def _(event):
|
||||||
|
open_message('About', f'Version {__version__}\nCreated {__created__}')
|
||||||
|
|
||||||
|
root = FloatContainer(
|
||||||
|
content=HSplit(
|
||||||
|
[Frame(body, title='Firefox Memory Manager'), Window(height=1, content=FormattedTextControl('q=quit, m=PID, h=help, s=setting, a=about'))]
|
||||||
|
),
|
||||||
|
floats=root_floats,
|
||||||
|
modal=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
style = Style.from_dict(
|
||||||
|
{
|
||||||
|
'frame.border': 'ansicyan',
|
||||||
|
'dialog.body': 'bg:#444444',
|
||||||
|
'dialog': 'bg:#888888',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Application(
|
||||||
|
layout=Layout(root),
|
||||||
|
key_bindings=kb,
|
||||||
|
style=style,
|
||||||
|
full_screen=True,
|
||||||
|
refresh_interval=args.interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.firefox_cmd:
|
||||||
|
firefox_proc = launch_firefox_with_limits(
|
||||||
|
args.firefox_cmd,
|
||||||
|
memory_high=args.max_mb,
|
||||||
|
swap_max=args.swap_max_mb, # **fixed here**
|
||||||
|
extra_args=args.firefox_extra,
|
||||||
|
unit_name=args.unit_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def monitor_loop():
|
||||||
|
nonlocal firefox_proc
|
||||||
|
while not terminate_flag.is_set():
|
||||||
|
try:
|
||||||
|
refresh_body()
|
||||||
|
except:
|
||||||
|
logger.exception('')
|
||||||
|
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
# stop()
|
||||||
|
|
||||||
|
terminate_flag = threading.Event()
|
||||||
|
t = threading.Thread(target=monitor_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# refresh_body()
|
||||||
|
app.run(handle_sigint=True) # from prompt‑toolkit API :contentReference[oaicite:0]{index=0}
|
||||||
|
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -72,6 +72,7 @@ build-backend = "mesonpy"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
online-fxreader-pr34-commands = 'online.fxreader.pr34.commands:commands_cli'
|
online-fxreader-pr34-commands = 'online.fxreader.pr34.commands:commands_cli'
|
||||||
|
oom_firefox = 'online.fxreader.pr34.oom_firefox:main'
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
BIN
releases/whl/online_fxreader_pr34-0.1.5.42-py3-none-any.whl
(Stored with Git LFS)
Normal file
BIN
releases/whl/online_fxreader_pr34-0.1.5.42-py3-none-any.whl
(Stored with Git LFS)
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user