[+] 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 \
|
||||
online.fxreader.pr34
|
||||
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
|
||||
@ -122,7 +123,6 @@ dotfiles_fetch_platform:
|
||||
mkdir -p platform_dotfiles/$(PLATFORM)
|
||||
tar -cvf - \
|
||||
/etc/udev/rules.d/ \
|
||||
~/.local/bin/oom_firefox \
|
||||
~/.local/bin/systemd_gtk \
|
||||
~/.local/bin/gnome-shortcuts-macbook-air \
|
||||
/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'),
|
||||
# 'online.fxreader.uv',
|
||||
# ['c', 'cpp'],
|
||||
version: '0.1.5.41',
|
||||
version: '0.1.5.42',
|
||||
# default_options: [
|
||||
# 'cpp_std=c++23',
|
||||
# # '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]
|
||||
online-fxreader-pr34-commands = 'online.fxreader.pr34.commands:commands_cli'
|
||||
oom_firefox = 'online.fxreader.pr34.oom_firefox:main'
|
||||
|
||||
|
||||
[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