[+] 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:
Siarhei Siniak 2025-12-03 17:38:12 +03:00
parent a8d0f64419
commit c568d8d9a7
6 changed files with 474 additions and 443 deletions

@ -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
# — Memorymanagement 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"Killto target: {kill_to:.1f} MB ({args.kill_percent}%)",
f"Lowpriority 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 lowpriority 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 prompttoolkit 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',

@ -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
# — Memorymanagement 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'Killto target: {kill_to:.1f} MB ({args.kill_percent}%)',
f'Lowpriority 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 lowpriority 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 prompttoolkit 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

Binary file not shown.