Complementary daemon for HestiaCP that makes php-fpm workers and dovecot session processes use the existing HestiaCP cgroup mechanism.
HestiaCP is the owner of cgroups: it sets the primary resource limits (CPUQuota, MemoryMax, ...) and is the source of truth for users and packages. The daemon does not replace HestiaCP's Resource Limits and never touches a slice that already exists.
After a reboot nothing recreates the user-<uid>.slice slices on their own
(they only appear after the first Hestia event or a login), so the daemon
recreates missing slices at startup, writing the user's HestiaCP limits
(CPU_QUOTA / CPU_QUOTA_PERIOD / MEMORY_LIMIT / SWAP_LIMIT from
user.conf) into cpu.max, memory.max and memory.swap.max - the same
conversion the old bash daemon used.
The daemon also:
- detects new php-fpm workers and dovecot session processes (
imap,pop3,managesieve,dovecot-lda- which run as the authenticated mail user after login), - finds their owner (uid) and the owner's HestiaCP package,
- moves them into a dedicated leaf scope of the owner's slice
(
user-<uid>.slice/psm-<uid>.scope). A leaf scope is required because cgroup v2 forbids processes directly inside a non-leaf cgroup: the user slice is non-leaf whenever asession-*.scopeexists (e.g. an active SSH/SFTP login), so writing the slice's owncgroup.procsfails withEBUSY. The scope inherits the slice's limits, so the workers still count against the user's quota, - applies optional cgroup v2 attributes from the config for that package
(e.g.
cpu.weight) - attributes HestiaCP does not expose, - enforces a hard memory policy on every user slice:
memory.max=MEMORY_LIMIT(hard limit),memory.high=max(no throttle) andmemory.oom.group=0(no group kill). HestiaCP only setsmemory.high- a throttle: once a slice's usage crosses it, the kernel stalls and
reclaims the whole slice instead of killing one process, which freezes the
user's PHP-FPM (the default pool has up to 8 workers, ~170 MB RSS each,
easily exceeding a small package's limit). With the hard limit, the kernel
OOM-kills a single worker (an occasional 500, respawned by PHP-FPM) instead
of freezing the site. The daemon re-asserts this policy every cycle because
HestiaCP's
v-update-user-cgroupresetsmemory.highto the throttle on every user/package event.
- a throttle: once a slice's usage crosses it, the kernel stalls and
reclaims the whole slice instead of killing one process, which freezes the
user's PHP-FPM (the default pool has up to 8 workers, ~170 MB RSS each,
easily exceeding a small package's limit). With the hard limit, the kernel
OOM-kills a single worker (an occasional 500, respawned by PHP-FPM) instead
of freezing the site. The daemon re-asserts this policy every cycle because
HestiaCP's
Console/cron/CLI processes are already placed in the same slices by systemd, so the whole account's usage sums against one limit.
A process counts as a dovecot session process only when it actually lives
under the dovecot.service cgroup - so a user running their own binary named
imap is not picked up. The login pre-auth processes (imap-login,
pop3-login, auth, anvil) run as dovenull/root and never match a
HestiaCP user's uid.
This is a native Rust rewrite of the original bash daemon. No shell, jq,
ps, grep or awk runs inside the monitor loop; the loop talks to
/proc and cgroupfs directly.
- HestiaCP on Ubuntu 22.04 / 24.04 (any edition the panel supports) -
the daemon reads
/usr/local/hestia/data/usersand/usr/local/hestia/data/packagesand works alongside the panel's Resource Limits, it does not replace them. - Linux cgroup v2 (systemd unified hierarchy). Check with
[ -f /sys/fs/cgroup/cgroup.controllers ]. On a v1-only host the daemon installs but the assignment features need v2. - root install (via
install.shor the systemd unit), a stablerustctoolchain for building from source (only ifcargois missing; the installer can fetch it), andgitto clone the repository. rustc also needs a C linker (cc/gcc);install.shinstallsbuild-essentialautomatically when no compiler is present. - No external runtime dependencies: the daemon uses
/procand cgroupfs directly, nops,grep,awk,jqor a separate agent. - The optional Exim addon (
--with-exim) additionally expects HestiaCP's Exim running in non-split mode (HestiaCP's default) and that the admin accepts a smallwarnrule injected into the generated Exim config.
src/main.rs entry point, start/stop/status/inspect CLI, SIGHUP reload orchestration
src/config.rs /etc/process-slice-manager.conf parser (global keys + [Package])
src/logger.rs leveled logging (ERROR < WARN < INFO < DEBUG < TRACE)
src/errors.rs central ManagerError type (no unwrap/expect in production)
src/users.rs reads /usr/local/hestia/data/users/<user>/user.conf + cache
src/proc.rs /proc scan, comm/uid reads, pid ticks/RSS, cgroup verification, self stats
src/monitor.rs polling loop, worker tracking, re-verification, counters, stats
src/cgroups.rs v2 attribute application + shared assign core (all providers)
src/providers/ assignment providers: discovery (php.rs, dovecot.rs) + event (exim.rs)
src/ipc.rs root-only unix socket; JSON protocol for provider events (Exim, future)
src/signals.rs SIGHUP/SIGTERM/SIGINT handling (atomic flags)
src/systemd.rs pid file + start/stop helpers
src/status.rs status socket server + client (JSON snapshot for `status`)
src/inspect.rs `inspect <user>`: native cgroup diagnostic (limits, workers, verification)
startloads config, initializes logging, registers signal handlers, writes the pid file and detects the cgroup hierarchy (expects v2).users::load_user_datareads everyuser.confunderHESTIA_USERS_DIR(/usr/local/hestia/data/users), skippingadmin, and records each user's package (PACKAGE) and resource limits (CPU_QUOTA,CPU_QUOTA_PERIOD,MEMORY_LIMIT,SWAP_LIMIT). UID mapping comes from/etc/passwd(the<user>_webmastercompanion account is ignored). All HestiaCP packages are also read fromHESTIA_PACKAGES_DIR(/usr/local/hestia/data/packages, every<name>.pkgfile is a package; the.shper-package hooks are ignored), sostatuslists every package - even ones no user is assigned to yet (e.g. the always-presentdefault).- Every user's
user-<uid>.sliceis ensured every cycle: a missing slice is created and the user's HestiaCP limits are written tocpu.max,memory.maxandmemory.swap.max(the old bash daemon's conversion). Existence is re-checked each cycle because systemd removes empty user slices (e.g. after the user's last session ends) - without the re-check the limits would silently disappear until the daemon was restarted or reloaded. A freshly recreated slice also has its package attributes re-applied. A slice that cannot be created is retried with a backoff. 3b. The hard memory policy is enforced on every existing slice too:memory.max=MEMORY_LIMIT,memory.high=max,memory.oom.group=0. HestiaCP setsmemory.high(a throttle) viav-update-user-cgroupon every user/package event, so this is re-asserted each cycle. - The optional attributes configured for each user's package are applied to that user's slice at startup / on reload for every user. A slice that still does not exist is retried with a backoff.
- The monitor loop scans
/proc; php-fpm workers are matched bycommprefix and their UID, dovecot session processes bycomm+ thedovecot.servicecgroup + their UID, then both are moved into theuser-<uid>.slice/psm-<uid>.scopeleaf scope. Non-php pids go into a negative cache (check_expiry) socommis not re-read every cycle. - Interactive SSH/SFTP sessions (
sshd,sftp-server,sftp,scp) are handled in the opposite direction:pam_systemdplaces them inuser-<uid>.slice/session-*.scope, so the user's own limits would throttle or (memory.oom.group) kill the session while their PHP-FPM is under load - breaking SFTP/file-manager transfers exactly then. Withexempt_ssh_sftp(defaulttrue) the daemon relocates the session's processes into a dedicated leaf scope (user.slice/psm-<uid>-session-<id>) that carries no per-user limits, keeping interactive file transfer usable under load. SIGHUPreloads config + user data, rebuilds all caches, re-ensures the slices and re-applies the package attributes without restarting the daemon. The same reload runs periodically (refresh_interval, default 300 s) because HestiaCP's package hooks fire on user creation / package change but not onv-delete-user- the periodic refresh is how the daemon notices a deleted account and reclaims its slice.
- does not create users, packages, or a separate cgroup tree,
- creates missing
user-<uid>.sliceslices only - it never modifies an existing slice's primary limits (CPUQuota/MemoryMaxcome from HestiaCP), - removes a slice only when the owning user no longer exists in HestiaCP
data and the slice is empty (no processes):
rmdiron cgroup v2 fails withEBUSYwhile the slice still holds processes, so live workloads are never touched. Non-empty slices of deleted users are simply left running, - never overrides a limit HestiaCP already applied.
Copy process-slice-manager.conf.example to
/etc/process-slice-manager.conf. Global keys can also be set via
PSM_<KEY> environment variables. There is no compile-time configuration.
Packages are auto-discovered, not listed by hand. The daemon reads the
packages HestiaCP already maintains from hestia_packages_dir
(/usr/local/hestia/data/packages, every <name>.pkg file is a package; the
.sh files next to them are Hestia's own per-package hooks and are ignored)
and maps each user to its package via the user's user.conf. The panel stays
the single source of truth: add, rename or remove a package in HestiaCP and
the daemon picks it up on its next reload - which happens automatically when a
user is added or a package changes (the installed <Package>.sh hooks send
SIGHUP).
The [Package] sections below are entirely optional extensions - they only
add extra cgroup v2 attributes HestiaCP does not expose. They never define
packages. A package with no section simply gets no extensions; the core
behaviour (moving php-fpm/Dovecot/Exim processes into the account's
user-<uid>.slice) still works for it, because that relies on the limits
HestiaCP already set. The shipped Mini/Pro/Business/default sections
are examples - you can delete them all and the daemon runs fine.
# Global settings
poll_interval = 2
log_level = info
hestia_packages_dir = /usr/local/hestia/data/packages
# Assignment providers (IPC socket is bound only when this is non-empty).
# Exim reports pid + $authenticated_id after SMTP AUTH; the daemon moves the
# process into the owner's user slice. No Exim config changes are required
# for the daemon side.
# providers = exim
# ipc_socket = /run/process-slice-manager-ipc.sock
# ipc_socket_group = Debian-exim
# exim_bin = /usr/sbin/exim4
# Optional cgroup v2 attributes per package
[default]
[Mini]
cpu.weight = 200
[Pro]
cpu.weight = 500- Each
[Package]section is optional; the name must match a<name>.pkgpackage in HestiaCP (hestia_packages_dir), which is also thePACKAGEvalue in the user'suser.conf. defaultalways exists in HestiaCP, so it is a safe catch-all section for attributes that should apply to users whose package has no section.- An attribute is applied only if it is listed in a package section; everything else (including all of HestiaCP's settings) is left untouched.
- Boolean tokens (
true/false/yes/no/on/off) are written as1/0. - A percentage value (e.g.
memory.swap.max = 50%) is resolved against the slice's own finitememory.swap.max(set by HestiaCP) - or the parent's if the slice has none - and converted to absolute bytes. Unresolvable percentages are skipped with a warning. - Everything else is passed through verbatim to the cgroup attribute file.
Supported attribute keys:
| attribute | meaning |
|---|---|
memory.swap.max |
maximum swap usage |
cpu.weight |
cpu share weight (default 100, range 1..10000) |
cpu.uclamp.min |
minimum cpu utilization clamp |
cpu.uclamp.max |
maximum cpu utilization clamp |
io.weight |
io weight (default 100, range 1..10000) |
memory.zswap.max |
maximum zswap pool usage |
memory.reclaim |
proactive reclaim |
pids.max |
maximum number of processes in the slice |
memory.high, memory.swap.high and memory.oom.group are not
supported: memory throttling and OOM group-kill freeze (or kill) the user's
whole slice under load, which is why the daemon always writes memory.high
= max and memory.oom.group = 0 instead (see the memory-policy note
above). Unknown keys in a section are ignored with a warning.
cargo build --release
install -m 0755 target/release/process-slice-manager /usr/local/bin/process-slice-manager
install -m 0644 process-slice-manager.service /etc/systemd/system/process-slice-manager.service
systemctl daemon-reload
systemctl enable --now process-slice-managerOr use install.sh (installs Rust if missing, logrotate config, the
HestiaCP user-create hooks and the psm-monitor.sh live viewer). Pass
--with-exim to also install the Exim addon (the notifier, the idempotent
template patch and its hourly self-healing timer):
sudo bash install.sh --with-eximTo remove the daemon and everything the installer put in place (binary, config, systemd unit, logrotate, HestiaCP package hooks, the monitor, and the whole Exim addon - including the warn rule injected into the Exim template, which is stripped before the config is regenerated):
sudo bash uninstall.shuninstall.sh stops and disables the services, removes the injected
psm-exim-notify rule from /etc/exim4/exim4.conf.template (then runs
update-exim4.conf and reloads Exim), deletes the notifier and patch script,
and only then removes the daemon files. HestiaCP's own user-<uid>.slice
cgroups and the daemon's log file are left untouched.
The --with-exim step is exactly what deploy/psm_install_exim_patch.sh does;
it can also be run standalone later without re-running the whole installer.
The daemon can receive assignment events from services that know the
authenticated identity (Exim after SMTP AUTH) instead of guessing the owner
from /proc. Exim only reports the process pid and $authenticated_id; the
daemon owns all cgroup logic.
Transport: a unix domain socket (default /run/process-slice-manager-ipc.sock),
one JSON request per line, requests capped at 64 KiB. The socket is bound only
when providers lists an event provider.
By default the socket is root-only (0600). HestiaCP's Exim refuses to run as
root (never_users = root) and always runs as Debian-exim, so to let the
post-AUTH notifier connect you must set ipc_socket_group to the Exim group
(typically Debian-exim); the daemon then chowns the socket to that group
and opens it 0660. The daemon still verifies every submitted PID is really
Exim before assigning, so opening the socket to the Exim group does not widen
what a client can make the daemon do.
{"source":"exim","pid":12345,"authenticated_id":"alice@example.com"}Response (one JSON line):
{"status":"ok"}
{"status":"error","message":"user not found"}The daemon resolves authenticated_id -> mail domain -> HestiaCP user -> uid
using its cached user data (rebuilt on reload, never queried per event) and
moves the process into the owner's user-<uid>.slice/psm-<uid>.scope. Before
moving anything
it verifies the pid still exists and /proc/<pid>/exe really is the Exim
binary (exim_bin), so a random pid cannot be pushed into a slice. Malformed
messages, unknown source values and unknown mail domains are rejected.
The daemon side needs no Exim changes; the deploy/ directory holds the
notifier and a patch script for HestiaCP's Exim:
| file | purpose |
|---|---|
deploy/psm-exim-notify |
Notifier invoked from the Exim ACL. Reads $authenticated_id from argv[1], connects to the daemon's IPC socket and submits {source,pid,authenticated_id}. Failure-safe (1s timeout, all errors swallowed) - mail is never blocked. |
deploy/psm_exim_patch.sh |
Injects the notifier call into the monolithic Exim config. Idempotent. |
deploy/psm_install_exim_patch.sh |
Installs the patch script plus a systemd timer (see below) so the rule survives apt upgrades and HestiaCP re-deploying its Exim template. |
deploy/systemd/psm-exim-patch.service |
oneshot unit that runs psm_exim_patch.sh. |
deploy/systemd/psm-exim-patch.timer |
Re-runs the patch hourly (OnCalendar=*-*-* *:00:00, RandomizedDelaySec=300, Persistent=true). |
deploy/exim-conf.d/ |
Split-mode config snippets. HestiaCP runs Exim in non-split mode, so these are kept as a reference only and are not applied. |
HestiaCP's Exim runs in non-split mode (dc_use_split_config='false'): the
generated config comes from /etc/exim4/exim4.conf.template and conf.d/
files are ignored. psm_exim_patch.sh therefore edits the template directly -
it inserts a warn rule into acl_check_data, right after the header so it
runs first:
warn
condition = ${if def:authenticated_id}
condition = ${run{/usr/local/bin/psm-exim-notify $authenticated_id}{yes}{no}}
The rule is idempotent (re-running the patch replaces an older injected rule,
never stacks duplicates) and backs the template up to
exim4.conf.template.bak.psm before editing. The script then runs
update-exim4.conf, reloads Exim and verifies the notifier path appears in the
generated /var/lib/exim4/config.autogenerated - but only when something
actually changed, so a periodic no-op invocation does not reload the service.
Install the addon on the server:
install -m 0755 deploy/psm-exim-notify /usr/local/bin/psm-exim-notify
sudo bash deploy/psm_install_exim_patch.shpsm_install_exim_patch.sh copies psm_exim_patch.sh to
/usr/local/bin/psm_exim_patch.sh, installs the two systemd units, starts the
timer and runs the patch once. The timer re-applies the patch every hour.
Why the timer is needed. /etc/exim4/exim4.conf.template is a conffile of
the exim4-config package, and HestiaCP keeps its own master copy in
/usr/local/hestia/install/deb/exim/ which it re-deploys through
v-change-sys-service-config. An apt upgrade of exim4-config or a HestiaCP
service-config change can therefore overwrite the injected rule. Normal HestiaCP
mail changes (v-add-mail-domain, ...) only touch the /etc/exim4/domains/
symlinks and regenerate /var/lib/exim4/config.autogenerated from the
template, so the patch survives those - but the self-healing timer guarantees
the rule is restored within an hour no matter what reset it.
Exim's ${run{cmd args}{string1}{string2}} passes the command's arguments on
the command line (the command's stdin is empty) and expands string1 on
success, string2 on failure. So $authenticated_id is passed as argv[1] and
the condition evaluates to a proper boolean (yes/no). The notifier sends
the pid of its parent process ($PPID) - the Exim SMTP connection process -
which is what the daemon verifies and moves into the user's slice.
process-slice-manager start
process-slice-manager stop
process-slice-manager status # reads a live snapshot over the unix socket
process-slice-manager inspect <user> # native cgroup diagnostic for one user
systemctl reload process-slice-manager # SIGHUP: full reloadstatus reports users/packages/known processes, assignment counters, reloads,
loop timings, daemon CPU and RAM:
$ process-slice-manager status
Daemon running: yes
Version: 1.3
Users: 39
Packages: 4
Known processes: 8
...
process-slice-manager inspect <user> prints a native diagnostic of the
user's cgroup slice, reading only /proc, /sys/fs/cgroup and the user cache
(no ps/grep/awk/jq). It shows the slice's limits, the controllers the
kernel exposes, live runtime statistics and whether every running worker
(php-fpm or a dovecot session process) is actually inside the expected
user-<uid>.slice (or the daemon's psm-<uid>.scope leaf beneath it):
$ process-slice-manager inspect alice
User: alice (uid 1001)
Package: Business
Slice: user.slice/user-1001.slice
Cgroup: v2
Controllers:
cpu memory io pids
Limits:
cpu.max 400000 100000
memory.max 4.0 GB
...
Runtime:
cpu.stat usage_usec 10295893
CPU throttled 0
memory.current 176 MB
Workers:
PID COMM KIND CGROUP STATUS
1234 php-fpm8.2 php /user.slice/user-1001.slice/psm-1001.scope ✔ OK
9876 imap dovecot /user.slice/user-1001.slice/psm-1001.scope ✔ OK
Verification: ✔ OK (all 2 workers in user.slice/user-1001.slice/psm-1001.scope)
Missing attribute files are rendered as N/A; a missing slice or an unknown
user are reported as errors.
psm-monitor.sh is a small live viewer that watches the same things the
daemon assigns, but from the outside: it refreshes once a second and prints a
ps-style table of PHP-FPM, Exim and Dovecot processes with their
current cgroup and an OK/SYSTEM/FAIL verdict per row. It is installed to
/usr/local/bin/psm-monitor.sh by install.sh and is meant to be run
manually in a terminal:
sudo psm-monitor.shIt must run as root (or a user in the /proc hidepid group): HestiaCP
mounts /proc with hidepid=invisible, so a regular shell user cannot see the
Exim/Dovecot connection processes at all.
Status logic per row:
- PHP-FPM workers are matched by
comm; they run as the account's real uid, so the expected slice isuser-<uid>.slice:OKwhen the process is inside it,FAILotherwise. - Exim/Dovecot run as their own service users (uid < 1000), so the process
uid cannot identify the target slice. The verdict keys off the cgroup
instead: a connection process the daemon has moved into a
user-<uid>.sliceshowsOK; one still insystem.sliceshowsSYSTEM. Both are normal -SYSTEMjust means "not (yet) assigned to a user slice".
Example while an authenticated SMTP session is held open:
PID USER UID SERVICE CGROUP STATUS
476879 Debian-+ 108 EXIM /system.slice/exim4.service SYSTEM
943450 Debian-+ 108 EXIM /user.slice/user-1008.slice OK
940577 alice 1024 PHP-FPM /user.slice/user-1024.slice OK
The last EXIM row is the connection process that the daemon moved from the Exim service slice into user 1008's slice after SMTP AUTH.
Pure logic (config parser, user.conf/passwd parsing, /proc self-stats) is covered by unit tests in each module:
cargo testReleased under the MIT License. See LICENSE for the full text.