From 62ed103760cb0929dff48741a856234b31d6ae08 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 12:32:31 -0700 Subject: [PATCH 01/10] Fix pystemd.run waits and pystemd.futures on systemd 259 / Python 3.14 pystemd.run: - wait=True / wait_for_activation=True could block forever: systemd only emits unit PropertiesChanged signals while some client has called Manager.Subscribe(), and run() only eavesdrops via BecomeMonitor. On a desktop something else is usually subscribed; on a minimal host (e.g. a Fedora 44 cloud VM) nobody is. Subscribe before starting the unit. - Drain every message sd-bus has buffered on each wakeup. One socket read can bring several signals, and select() does not fire again for the ones already read, so processing one message per wakeup could miss the state change we are waiting for. pystemd.futures: - Python 3.14 made forkserver the default start method on Linux, which broke TransientUnitPoolExecutor ("SemLock created in a fork context is being shared with a process in a spawn context"). Workers now subclass ForkProcess, which enter_unit relies on anyway. - Start the anchor unit with Type=exec. With Type=simple the unit is reported running right after fork, while systemd >= 255's executor is still root, so enter_unit copied uid 0 and User= was ignored. - The pool's anchor process is now `tail --pid=` instead of `sleep infinity` (as TransientUnitProcess already does), so the unit does not leak when the parent dies without reaching __exit__. Tested on Fedora 44 (systemd 259, kernel 6.19, aarch64, Python 3.14): futures.run with User=nobody + PrivateNetwork, CPUQuota and MemoryMax; examples/future_cpucap_{method,process,pool}.py; unit tests pass. --- CHANGES.md | 5 ++++ pystemd/futures.py | 19 +++++++++++---- pystemd/run.py | 59 ++++++++++++++++++++++++++++------------------ 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 790937d..f2c13d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,11 @@ the one you find pypi, but its the one in development. dev ===== +* Fix `pystemd.run(wait=True)` / `wait_for_activation=True` hanging forever on hosts where no other client is subscribed to systemd signals +* Fix `pystemd.run` missing unit state changes when several D-Bus signals arrive at once +* Fix `pystemd.futures` on Python 3.14+, where the default multiprocessing start method is no longer fork +* Fix `pystemd.futures` ignoring `User=` on systemd >= 255 +* Fix `pystemd.futures.TransientUnitPoolExecutor` leaking its unit when the parent process is killed 0.15.3 ===== diff --git a/pystemd/futures.py b/pystemd/futures.py index db6b80e..7d2639b 100644 --- a/pystemd/futures.py +++ b/pystemd/futures.py @@ -3,8 +3,7 @@ import os from concurrent.futures import ProcessPoolExecutor from functools import cached_property -from multiprocessing import Process -from multiprocessing.context import BaseContext +from multiprocessing.context import BaseContext, ForkProcess from pathlib import Path from typing import Any, Sequence, cast @@ -34,10 +33,12 @@ def __init__( self.properties = properties self.user_mode = user_mode self.unit_name = unit_name + # The unit's main process lives exactly as long as we do, so the unit + # is not leaked if we die without reaching stop_unit() (e.g. SIGTERM). self.main_process_cmd = main_process or [ "/bin/bash", "-c", - "exec sleep infinity", + f"exec tail --pid={os.getpid()} -f /dev/null", ] super().__init__() @@ -49,6 +50,11 @@ def start_unit(self) -> pystemd.systemd1.Unit: name=self.unit_name, user_mode=self.user_mode, wait_for_activation=True, + # "exec": only report the unit running once the main process has + # exec'd, i.e. once its user and namespaces are final. With + # "simple", enter_unit could read the uid of the not-yet-dropped + # executor (systemd >= 255) and keep running as root. + service_type="exec", extra={ **self.properties, "Delegate": True, @@ -93,9 +99,12 @@ def enter_unit(unit): os.setuid(uid) -class _ProcessWithPreRun(Process): +class _ProcessWithPreRun(ForkProcess): """ - Parent class that its just a subprocess, but it execute a pre_run hook + Parent class that its just a subprocess, but it execute a pre_run hook. + + Always forks (enter_unit relies on it), even on Python 3.14+ where the + default start method on Linux is no longer fork. """ def __init__(self, *args, **kwargs) -> None: diff --git a/pystemd/run.py b/pystemd/run.py index edd9116..b7f14c5 100644 --- a/pystemd/run.py +++ b/pystemd/run.py @@ -301,6 +301,12 @@ def bus_factory(): monitor_fd = monbus.get_fd() sel.register(monitor_fd, EVENT_READ) + # systemd only emits unit PropertiesChanged signals while at least + # one client is subscribed. On a desktop someone usually is; on a + # minimal host nobody is, and we would wait forever. + # pyrefly: ignore [missing-argument] + manager.Manager.Subscribe() + # start the process # pyrefly: ignore [missing-argument] unit_start_job = manager.Manager.StartTransientUnit( @@ -330,29 +336,36 @@ def bus_factory(): # pyrefly: ignore [unbound-name] if monitor_fd in _in: - # pyrefly: ignore [unbound-name] - m = monbus.process() - if m.is_empty(): - continue - - m.process_reply(False) - if ( - # pyrefly: ignore [missing-attribute] - m.get_path() == unit.path - and m.body[0] == b"org.freedesktop.systemd1.Unit" - ): - _, message_job_path = m.body[1].get(b"Job", (0, b"/")) - if wait: - if ( - message_job_path != unit_start_job - and m.body[1].get(b"SubState") in EXIT_SUBSTATES - ): - break - elif wait_for_activation: - if message_job_path == unit_start_job and m.body[1].get( - b"SubState" - ) in (b"running",): - break + # Drain every message sd-bus has buffered: one read from the + # socket can bring several signals, and select() will not + # wake up again for the ones already read. + done = False + while not done: + # pyrefly: ignore [unbound-name] + m = monbus.process() + if m.is_empty(): + break + + m.process_reply(False) + if ( + # pyrefly: ignore [missing-attribute] + m.get_path() == unit.path + and m.body[0] == b"org.freedesktop.systemd1.Unit" + ): + _, message_job_path = m.body[1].get(b"Job", (0, b"/")) + sub_state = m.body[1].get(b"SubState") + if wait: + done = ( + message_job_path != unit_start_job + and sub_state in EXIT_SUBSTATES + ) + elif wait_for_activation: + done = ( + message_job_path == unit_start_job + and sub_state == b"running" + ) + if done: + break if _wait_polling and not _in and unit.Service.MainPID == 0: # on usermode the subscribe to events does not work that well From b6d812adf13f94771cc7e121bb3cb693b85c257d Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 13:05:50 -0700 Subject: [PATCH 02/10] Add PrivatePIDs, ProtectHostnameEx and SystemCallErrorNumber signatures Without them pystemd.run(extra={...}) raises KeyError for these properties. Types are what systemd 259 exposes on org.freedesktop.systemd1.Service: - PrivatePIDs: "s" ("yes"/"no"; systemd >= 257), not a boolean - ProtectHostnameEx: "(ss)", e.g. ("private", "myhostname") - SystemCallErrorNumber: "i", e.g. errno.EPERM so SystemCallFilter= denies with an error instead of killing the process Tested on Fedora 44 (systemd 259): a transient unit with PrivatePIDs="yes", ProtectHostnameEx=("private", "pycon-unit"), SystemCallFilter=(False, ["socket"]) and SystemCallErrorNumber=EPERM runs as PID 1 with its own hostname, and socket() fails with EPERM. --- CHANGES.md | 1 + pystemd/systemd1/unit_signatures.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index f2c13d3..927b478 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,7 @@ dev * Fix `pystemd.futures` on Python 3.14+, where the default multiprocessing start method is no longer fork * Fix `pystemd.futures` ignoring `User=` on systemd >= 255 * Fix `pystemd.futures.TransientUnitPoolExecutor` leaking its unit when the parent process is killed +* Add `PrivatePIDs`, `ProtectHostnameEx` and `SystemCallErrorNumber` to systemd1 KNOWN_UNIT_SIGNATURES 0.15.3 ===== diff --git a/pystemd/systemd1/unit_signatures.py b/pystemd/systemd1/unit_signatures.py index 76b5416..08f3221 100644 --- a/pystemd/systemd1/unit_signatures.py +++ b/pystemd/systemd1/unit_signatures.py @@ -94,6 +94,7 @@ b"LockPersonality": b"b", b"SupplementaryGroups": b"as", b"SystemCallArchitectures": b"as", + b"SystemCallErrorNumber": b"i", b"SystemCallFilter": b"(bas)", # timeouts b"RuntimeMaxUSec": b"t", @@ -168,8 +169,10 @@ b"PrivateIPC": b"b", b"PrivateMounts": b"b", b"PrivateNetwork": b"b", + b"PrivatePIDs": b"s", b"PrivateTmp": b"b", b"PrivateUsers": b"b", + b"ProtectHostnameEx": b"(ss)", b"ProtectKernelTunables": b"b", b"ProtectKernelModules": b"b", b"ProtectControlGroups": b"b", From 197019d0ac89528f70d54fd531a1518fd27c84a7 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 14:37:17 -0700 Subject: [PATCH 03/10] Fix wait_for_activation hanging when the unit fails to start wait_for_activation=True only stopped waiting when it saw SubState "running" for the start job. A Type=notify or Type=exec unit that fails during startup never gets there (it goes straight to "failed"), so pystemd.run() blocked forever. Also stop once the start job is over and the unit is in an exit substate (exited/failed/dead), the same condition wait=True uses; with raise_on_fail the caller then gets PystemdRunError as usual. Tested on systemd 259: Type=notify `sh -c "exit 3"` and Type=exec `/nonexistent` now return in ~20ms as failed/failed (both hung before); a Type=notify unit that sends READY=1 still waits for it. --- CHANGES.md | 1 + pystemd/run.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 927b478..837f366 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,7 @@ dev * Fix `pystemd.futures` ignoring `User=` on systemd >= 255 * Fix `pystemd.futures.TransientUnitPoolExecutor` leaking its unit when the parent process is killed * Add `PrivatePIDs`, `ProtectHostnameEx` and `SystemCallErrorNumber` to systemd1 KNOWN_UNIT_SIGNATURES +* Fix `pystemd.run(wait_for_activation=True)` hanging forever when the unit fails to start 0.15.3 ===== diff --git a/pystemd/run.py b/pystemd/run.py index b7f14c5..e9b9ab8 100644 --- a/pystemd/run.py +++ b/pystemd/run.py @@ -360,9 +360,14 @@ def bus_factory(): and sub_state in EXIT_SUBSTATES ) elif wait_for_activation: + # active, or the start job is over and the unit + # already exited (e.g. it failed to start) done = ( message_job_path == unit_start_job and sub_state == b"running" + ) or ( + message_job_path != unit_start_job + and sub_state in EXIT_SUBSTATES ) if done: break From 14f786d4388f1d4627ad23274096fb72c6ea0205 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 14:37:17 -0700 Subject: [PATCH 04/10] Add FileDescriptorStoreMax and FileDescriptorStorePreserve signatures Needed to use the file descriptor store from pystemd.run(extra={...}) (otherwise KeyError). Types as exposed by systemd 259 on org.freedesktop.systemd1.Service: FileDescriptorStoreMax "u", FileDescriptorStorePreserve "s" ("no" / "yes" / "restart"). Tested: a Type=notify service with FileDescriptorStoreMax=1 stores a memfd (FDSTORE=1, FDNAME=db) and gets it back via LISTEN_FDS across four restarts; Service.DumpFileDescriptorStore() lists it. --- CHANGES.md | 1 + pystemd/systemd1/unit_signatures.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 837f366..7ce8a49 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ dev * Fix `pystemd.futures.TransientUnitPoolExecutor` leaking its unit when the parent process is killed * Add `PrivatePIDs`, `ProtectHostnameEx` and `SystemCallErrorNumber` to systemd1 KNOWN_UNIT_SIGNATURES * Fix `pystemd.run(wait_for_activation=True)` hanging forever when the unit fails to start +* Add `FileDescriptorStoreMax` and `FileDescriptorStorePreserve` to systemd1 KNOWN_UNIT_SIGNATURES 0.15.3 ===== diff --git a/pystemd/systemd1/unit_signatures.py b/pystemd/systemd1/unit_signatures.py index 08f3221..1db504e 100644 --- a/pystemd/systemd1/unit_signatures.py +++ b/pystemd/systemd1/unit_signatures.py @@ -65,6 +65,8 @@ b"DynamicUser": b"b", b"Personality": b"s", b"Description": b"s", + b"FileDescriptorStoreMax": b"u", + b"FileDescriptorStorePreserve": b"s", b"NotifyAccess": b"s", b"BusName": b"s", b"RemainAfterExit": b"b", From 22d3286102b0775ff703d889b11443373f7eeb5a Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 15:04:06 -0700 Subject: [PATCH 05/10] Add fds= to daemon.notify, and daemon.listen_fds_with_names - notify(unset_environment, *states, fds=None, **kwstates): when `fds` is given, call sd_pid_notify_with_fds() so the fds travel with the message; without it (or with an empty list), sd_notify() exactly as before. `fds` is keyword-only, and there is no FDS= notify state for it to shadow. It takes a single fd or a list of them; each fd is an int or an object with fileno(). fds=0 sends fd 0 (it is not "no fds"), and str/bytes are rejected with TypeError: bytes would otherwise iterate as ints and silently send the wrong fds. e.g. notify(False, fds=[memfd], fdstore=1, fdname="db") hands the memfd to systemd's file descriptor store. - listen_fds_with_names(unset_environment=0): sd_listen_fds_with_names(), returned as {name: [fd, ...]}; this is how a restarted service gets its stored fds back (and how named socket activation fds are found). Names are not unique (a socket unit with several ListenStream= passes all its fds under one name; FDNAME= can repeat), hence the lists. Tested on systemd 259: a Type=notify service with FileDescriptorStoreMax=1 stores a memfd holding a SQLite database with notify(fds=...) and gets it back with listen_fds_with_names()["db"][0] across four restarts; fds= lands in the store too, and two fds stored under the same FDNAME both come back (LISTEN_FDNAMES=same:same). --- CHANGES.md | 1 + pystemd/daemon.pyi | 13 +++++-- pystemd/daemon.pyx | 89 ++++++++++++++++++++++++++++++++++++++++++-- pystemd/dbusc.pxd | 4 ++ tests/test_daemon.py | 28 ++++++++++++++ 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 7ce8a49..c60c050 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ dev * Add `PrivatePIDs`, `ProtectHostnameEx` and `SystemCallErrorNumber` to systemd1 KNOWN_UNIT_SIGNATURES * Fix `pystemd.run(wait_for_activation=True)` hanging forever when the unit fails to start * Add `FileDescriptorStoreMax` and `FileDescriptorStorePreserve` to systemd1 KNOWN_UNIT_SIGNATURES +* Add `fds=` to `pystemd.daemon.notify` (sd_pid_notify_with_fds) and add `pystemd.daemon.listen_fds_with_names` (file descriptor store / named socket activation) 0.15.3 ===== diff --git a/pystemd/daemon.pyi b/pystemd/daemon.pyi index a288b01..8fecaeb 100644 --- a/pystemd/daemon.pyi +++ b/pystemd/daemon.pyi @@ -5,7 +5,10 @@ # the root directory of this source tree. # -from typing import Union +from typing import Iterable, Optional, Protocol, Union + +class _HasFileno(Protocol): + def fileno(self) -> int: ... LISTEN_FDS_START: int @@ -14,7 +17,11 @@ class PystemdDaemonError(Exception): def listen_fds(unset_environment: bool) -> int: ... def notify( - unset_environment: bool, *args: Union[str, bytes], **kwargs: Union[int, str] -) -> None: ... + unset_environment: bool, + *args: Union[str, bytes], + fds: Optional[Union[int, _HasFileno, Iterable[Union[int, _HasFileno]]]] = None, + **kwargs: Union[int, str], +) -> int: ... +def listen_fds_with_names(unset_environment: bool = ...) -> dict[str, list[int]]: ... def booted() -> bool: ... def watchdog_enabled(unset_environment: bool) -> int: ... diff --git a/pystemd/daemon.pyx b/pystemd/daemon.pyx index e7671f3..0d36db0 100644 --- a/pystemd/daemon.pyx +++ b/pystemd/daemon.pyx @@ -9,6 +9,7 @@ from libc.stdint cimport uint64_t +from libc.stdlib cimport free, malloc cimport pystemd.dbusc as dbusc @@ -24,7 +25,39 @@ def listen_fds(int unset_environment=0): return dbusc.sd_listen_fds(unset_environment) -def notify(int unset_environment, *states, **kwstates): +def listen_fds_with_names(int unset_environment=0): + """ + interface to systemd sd_listen_fds_with_names: the file descriptors passed + to us (socket activation, or the file descriptor store handing back what we + stored with FDSTORE=1), as a dict of {name: [fd, ...]}. fds start at + LISTEN_FDS_START; names come from FileDescriptorName= / FDNAME=. + + Names are not unique: a socket unit with several ListenStream= passes all + its fds under the same name, and FDNAME= can be reused; hence the lists. + + usage: + * `fd = pystemd.daemon.listen_fds_with_names()["db"][0]` + * `for fd in pystemd.daemon.listen_fds_with_names().get("web.socket", []): ...` + """ + cdef char **names = NULL + cdef int n = dbusc.sd_listen_fds_with_names(unset_environment, &names) + if n < 0: + raise PystemdDaemonError(f"sd_listen_fds_with_names failed: {n}") + + fds = {} + try: + for i in range(n): + name = names[i].decode() if names != NULL else "unknown" + fds.setdefault(name, []).append(LISTEN_FDS_START + i) + return fds + finally: + if names != NULL: + for i in range(n): + free(names[i]) + free(names) + + +def notify(int unset_environment, *states, fds=None, **kwstates): """ interface to systemd sd_notify, @@ -34,6 +67,11 @@ def notify(int unset_environment, *states, **kwstates): states/kwstates = array with states statements that are 'IDENTIFIER=VALUE'. e.g: 'READY=1' or keywords states like `ready=1` (This will be clear in usage). + fds = optional file descriptor, or list of them (ints, or objects with a + fileno() method), to send along with the message (sd_pid_notify_with_fds). Mostly + for the file descriptor store: with `fdstore=1` systemd keeps them while + the service restarts and hands them back on the next start (see + listen_fds_with_names); the unit needs FileDescriptorStoreMax= > 0. usage: * `pystemd.daemon.notify(1, ready=1)`: this will signal `READY=1` to systemd @@ -44,18 +82,63 @@ def notify(int unset_environment, *states, **kwstates): * `pystemd.daemon.notify(False, ready=1, status='gime gime gime')`: will signal systemd that the app is ready and also set the status to 'gime gime gime'. This command does not clean the notify environment. + * `pystemd.daemon.notify(False, fds=[memfd], fdstore=1, fdname="db")`: + ask systemd to keep `memfd` for us, under the name "db". For info on sd_notify, check https://github.com/systemd/systemd/blob/\ master/src/systemd/sd-daemon.h#L173-L232 """ + state = _join_states(states, kwstates) + if fds is None: + return dbusc.sd_notify(unset_environment, state) + + fd_list = _fd_list(fds) + if not fd_list: + return dbusc.sd_notify(unset_environment, state) + + cdef unsigned n_fds = len(fd_list) + cdef int *c_fds = malloc(n_fds * sizeof(int)) + if c_fds == NULL: + raise MemoryError() + + try: + for i, fd in enumerate(fd_list): + c_fds[i] = fd + return dbusc.sd_pid_notify_with_fds(0, unset_environment, state, c_fds, n_fds) + finally: + free(c_fds) + + +def _fd_list(fds): + """ + normalize notify()'s `fds`: a single fd (int, or object with fileno()) or an + iterable of them, to a list of ints. + """ + if isinstance(fds, (str, bytes, bytearray)): + # bytes would iterate as ints, silently sending the wrong fds + raise TypeError(f"fds must be a file descriptor or a list of them, not {type(fds).__name__}") + if isinstance(fds, int) or hasattr(fds, "fileno"): + fds = [fds] + + fd_list = [] + for fd in fds: + if isinstance(fd, int): + fd_list.append(fd) + elif hasattr(fd, "fileno"): + fd_list.append(fd.fileno()) + else: + raise TypeError(f"not a file descriptor: {fd!r} (expected an int or an object with fileno())") + return fd_list + + +def _join_states(states, kwstates): pystates = [x2char_star(s) for s in states] pystates.extend( b'='.join([x2char_star(k.upper()), x2char_star(v, convert_all=True)]) for k, v in kwstates.items() ) - state = b'\n'.join(pystates) - return dbusc.sd_notify(unset_environment, state) + return b'\n'.join(pystates) def booted(): diff --git a/pystemd/dbusc.pxd b/pystemd/dbusc.pxd index a1134fa..333fa80 100644 --- a/pystemd/dbusc.pxd +++ b/pystemd/dbusc.pxd @@ -7,6 +7,7 @@ # the root directory of this source tree. # +from posix.types cimport pid_t from libc.stdint cimport ( int16_t, int32_t, @@ -37,6 +38,9 @@ cdef extern from "systemd/sd-daemon.h": int SD_LISTEN_FDS_START int sd_listen_fds(int unset_environment) int sd_notify(int unset_environment, const char *state) + int sd_pid_notify_with_fds(pid_t pid, int unset_environment, const char *state, + const int *fds, unsigned n_fds) + int sd_listen_fds_with_names(int unset_environment, char ***names) int sd_booted() int sd_watchdog_enabled(int unset_environment, uint64_t *usec) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index c0f42bf..b14462c 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -7,6 +7,7 @@ # the root directory of this source tree. # +import sys from unittest import TestCase from pystemd import daemon @@ -23,10 +24,37 @@ def test_notify(self): ) self.assertIn(notify_status, {0, 1}) + def test_notify_with_fds(self): + # without $NOTIFY_SOCKET this is a no-op that returns 0 + with open("/dev/null") as devnull: + notify_status = daemon.notify( + # pyrefly: ignore [bad-argument-type] + False, + fds=[devnull.fileno(), devnull], + fdstore=1, + fdname="test", + ) + self.assertIn(notify_status, {0, 1}) + + def test_notify_with_single_fd(self): + # a single fd (even 0) is a list of one, not "no fds" + for fd in (0, 1, sys.stdout): + # pyrefly: ignore [bad-argument-type] + self.assertIn(daemon.notify(False, fds=fd, ready=1), {0, 1}) + + def test_notify_fds_rejects_non_fds(self): + for bad in (b"\x03", "abc", [object()], [1.5]): + with self.assertRaises(TypeError): + # pyrefly: ignore [bad-argument-type] + daemon.notify(False, fds=bad, ready=1) + class TestListen(TestCase): def test_notify(self): self.assertTrue(daemon.listen_fds(False) >= 0) + def test_listen_fds_with_names(self): + self.assertIsInstance(daemon.listen_fds_with_names(False), dict) + def test_listen_fds_start(self): self.assertEqual(daemon.LISTEN_FDS_START, 3) From 3744d5addf63ceef119cf7002d62f9df67b709af Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 16:32:33 -0700 Subject: [PATCH 06/10] Add daemon.store_fd and daemon.store_fds Helpers for systemd's file descriptor store, on top of notify(fds=...): - store_fd(name, fds, unset_environment=False, **kwstates): hand a single fd or a list of fds to the store under `name`; the same as notify(unset_environment, fds=fds, fdstore=1, fdname=name, **kwstates). - store_fds({name: fds, ...}, unset_environment=False, **kwstates): several names at once. FDNAME= covers a whole notify message, so this sends one message per name (not atomic); unset_environment and the extra states (e.g. ready=1) only go with the last message, since unsetting $NOTIFY_SOCKET earlier would make the remaining sends no-ops. Returns the lowest notify() result. unset_environment can be passed positionally (like notify()) or as a keyword, for readability: store_fds({...}, unset_environment=True). Both validate names first (1-255 printable ASCII characters, no ':', as LISTEN_FDNAMES is ':'-separated) and raise ValueError: systemd ignores an invalid FDNAME= silently, so the fds would just never be stored. Together with listen_fds_with_names() -> {name: [fd, ...]}, storing and getting fds back is a dict both ways. Tested on systemd 259: store_fds({"a": fd, "b": [fd1, fd2], "c": fd}, unset_environment=True, ready=1, status="stored") stores all four fds under three names and activates the unit with the status set; store_fd() is used by a memfd, chat-server (listening + client sockets), pty and pidfd example that all get their fds back across restarts. --- CHANGES.md | 1 + pystemd/daemon.pyi | 14 ++++++++++-- pystemd/daemon.pyx | 51 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_daemon.py | 26 ++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c60c050..0448524 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ dev * Fix `pystemd.run(wait_for_activation=True)` hanging forever when the unit fails to start * Add `FileDescriptorStoreMax` and `FileDescriptorStorePreserve` to systemd1 KNOWN_UNIT_SIGNATURES * Add `fds=` to `pystemd.daemon.notify` (sd_pid_notify_with_fds) and add `pystemd.daemon.listen_fds_with_names` (file descriptor store / named socket activation) +* Add `pystemd.daemon.store_fd` and `pystemd.daemon.store_fds` helpers for the file descriptor store 0.15.3 ===== diff --git a/pystemd/daemon.pyi b/pystemd/daemon.pyi index 8fecaeb..650ac68 100644 --- a/pystemd/daemon.pyi +++ b/pystemd/daemon.pyi @@ -5,11 +5,13 @@ # the root directory of this source tree. # -from typing import Iterable, Optional, Protocol, Union +from typing import Iterable, Mapping, Optional, Protocol, Union class _HasFileno(Protocol): def fileno(self) -> int: ... +_Fds = Union[int, _HasFileno, Iterable[Union[int, _HasFileno]]] + LISTEN_FDS_START: int class PystemdDaemonError(Exception): @@ -19,9 +21,17 @@ def listen_fds(unset_environment: bool) -> int: ... def notify( unset_environment: bool, *args: Union[str, bytes], - fds: Optional[Union[int, _HasFileno, Iterable[Union[int, _HasFileno]]]] = None, + fds: Optional[_Fds] = None, **kwargs: Union[int, str], ) -> int: ... def listen_fds_with_names(unset_environment: bool = ...) -> dict[str, list[int]]: ... +def store_fd( + name: str, fds: _Fds, unset_environment: bool = ..., **kwargs: Union[int, str] +) -> int: ... +def store_fds( + fds_by_name: Mapping[str, _Fds], + unset_environment: bool = ..., + **kwargs: Union[int, str], +) -> int: ... def booted() -> bool: ... def watchdog_enabled(unset_environment: bool) -> int: ... diff --git a/pystemd/daemon.pyx b/pystemd/daemon.pyx index 0d36db0..77f029b 100644 --- a/pystemd/daemon.pyx +++ b/pystemd/daemon.pyx @@ -110,6 +110,57 @@ master/src/systemd/sd-daemon.h#L173-L232 free(c_fds) +def store_fd(name, fds, unset_environment=False, **kwstates): + """ + hand `fds` (a single fd or a list; ints or objects with fileno()) to + systemd's file descriptor store under `name`: systemd keeps them while the + service restarts, and listen_fds_with_names()[name] gives them back on the + next start. The unit needs FileDescriptorStoreMax= > 0. + + Same as notify(unset_environment, fds=fds, fdstore=1, fdname=name, **kwstates). + + usage: + * `pystemd.daemon.store_fd("db", memfd)` + * `pystemd.daemon.store_fd("listener", sock, ready=1)` + """ + _check_fdname(name) + return notify(unset_environment, fds=fds, fdstore=1, fdname=name, **kwstates) + + +def store_fds(fds_by_name, unset_environment=False, **kwstates): + """ + store_fd() for several names at once: `fds_by_name` is {name: fds}, where + fds is a single fd or a list. One FDNAME= covers a whole notify message, + so this sends one message per name (not atomically). `unset_environment` + and the extra states (e.g. ready=1) only go with the last message. + Returns the lowest notify() result (negative errno if any send failed). + + usage: + * `pystemd.daemon.store_fds({"listener": sock, "log": memfd}, ready=1)` + * `pystemd.daemon.store_fds({...}, True)` or `store_fds({...}, unset_environment=True)` + """ + items = list(fds_by_name.items()) + for name, _ in items: + _check_fdname(name) + if not items: + return notify(unset_environment, **kwstates) if kwstates else 0 + + results = [store_fd(name, fds) for name, fds in items[:-1]] + name, fds = items[-1] + results.append(store_fd(name, fds, unset_environment=unset_environment, **kwstates)) + return min(results) + + +def _check_fdname(name): + # systemd ignores invalid names *silently* (the fds are just not stored), + # so fail here instead: LISTEN_FDNAMES is ':'-separated, printable ASCII. + if ( + not isinstance(name, str) or not 0 < len(name) <= 255 + or ":" in name or not all(32 <= ord(c) < 127 for c in name) + ): + raise ValueError(f"invalid fd name {name!r}: 1-255 printable ASCII characters, no ':'") + + def _fd_list(fds): """ normalize notify()'s `fds`: a single fd (int, or object with fileno()) or an diff --git a/tests/test_daemon.py b/tests/test_daemon.py index b14462c..25267ea 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -49,6 +49,32 @@ def test_notify_fds_rejects_non_fds(self): daemon.notify(False, fds=bad, ready=1) +class TestStoreFds(TestCase): + # without $NOTIFY_SOCKET these are no-ops that return 0 + def test_store_fd(self): + self.assertIn(daemon.store_fd("one", 1), {0, 1}) + self.assertIn(daemon.store_fd("many", [1, sys.stderr], status="x"), {0, 1}) + + def test_store_fds(self): + self.assertIn(daemon.store_fds({"a": 1, "b": [1, 2]}, ready=1), {0, 1}) + self.assertEqual(daemon.store_fds({}), 0) + + def test_unset_environment_positional_or_keyword(self): + self.assertIn(daemon.store_fd("x", 1, False), {0, 1}) + self.assertIn(daemon.store_fd("x", 1, unset_environment=False), {0, 1}) + self.assertIn(daemon.store_fds({"x": 1}, False), {0, 1}) + self.assertIn(daemon.store_fds({"x": 1}, unset_environment=False), {0, 1}) + + def test_invalid_names(self): + for bad in ("", "a:b", "x" * 256, "tab\there", "ñ", None): + with self.assertRaises(ValueError): + # pyrefly: ignore [bad-argument-type] + daemon.store_fd(bad, 1) + with self.assertRaises(ValueError): + # pyrefly: ignore [bad-argument-type] + daemon.store_fds({"ok": 1, bad: 1}) + + class TestListen(TestCase): def test_notify(self): self.assertTrue(daemon.listen_fds(False) >= 0) From f82f7796bdf453291c609a470bbd0b2b5468cc24 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 16:58:56 -0700 Subject: [PATCH 07/10] Split pystemd.utils into a package pystemd/utils.py becomes pystemd/utils/, one module per kind of helper: - utils/x2.py: x2char_star, str2cmd, strlist2cmd, x2cmdlist (the old file, moved, so its history follows) - utils/unit.py: random_unit_name - utils/typing.py: T, unwrap utils/__init__.py re-exports all of them, so existing imports such as `from pystemd.utils import x2char_star` and `pystemd.utils.unwrap(...)` keep working unchanged (including mock.patch("pystemd.utils.random_unit_name") in the tests). setup.py lists packages explicitly, so pystemd.utils is added there. No behaviour change; this makes room for new helper modules (e.g. namespaces) without growing a single catch-all file. --- CHANGES.md | 1 + pystemd/utils/__init__.py | 27 +++++++++++++++++++++++++++ pystemd/utils/typing.py | 19 +++++++++++++++++++ pystemd/utils/unit.py | 15 +++++++++++++++ pystemd/{utils.py => utils/x2.py} | 15 +-------------- setup.py | 8 +++++++- 6 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 pystemd/utils/__init__.py create mode 100644 pystemd/utils/typing.py create mode 100644 pystemd/utils/unit.py rename pystemd/{utils.py => utils/x2.py} (92%) diff --git a/CHANGES.md b/CHANGES.md index 0448524..791d425 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,6 +16,7 @@ dev * Add `FileDescriptorStoreMax` and `FileDescriptorStorePreserve` to systemd1 KNOWN_UNIT_SIGNATURES * Add `fds=` to `pystemd.daemon.notify` (sd_pid_notify_with_fds) and add `pystemd.daemon.listen_fds_with_names` (file descriptor store / named socket activation) * Add `pystemd.daemon.store_fd` and `pystemd.daemon.store_fds` helpers for the file descriptor store +* Split `pystemd.utils` into a package (`pystemd.utils.{x2,unit,typing}`); existing imports keep working 0.15.3 ===== diff --git a/pystemd/utils/__init__.py b/pystemd/utils/__init__.py new file mode 100644 index 0000000..4fd6143 --- /dev/null +++ b/pystemd/utils/__init__.py @@ -0,0 +1,27 @@ +# +# Copyright (c) 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the LICENSE file in +# the root directory of this source tree. +# + +""" +Helpers used across pystemd. Everything that used to live in the +pystemd/utils.py module is re-exported here, so `from pystemd.utils import +x2char_star` (and friends) keeps working. +""" + +from pystemd.utils.typing import T, unwrap +from pystemd.utils.unit import random_unit_name +from pystemd.utils.x2 import str2cmd, strlist2cmd, x2char_star, x2cmdlist + +__all__ = [ + "T", + "random_unit_name", + "str2cmd", + "strlist2cmd", + "unwrap", + "x2char_star", + "x2cmdlist", +] diff --git a/pystemd/utils/typing.py b/pystemd/utils/typing.py new file mode 100644 index 0000000..eceaa25 --- /dev/null +++ b/pystemd/utils/typing.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the LICENSE file in +# the root directory of this source tree. +# + +from __future__ import annotations + +from typing import TypeVar + +T = TypeVar("T") + + +def unwrap(obj: T | None, msg="object was None") -> T: + if obj is None: + raise ValueError(msg) + return obj diff --git a/pystemd/utils/unit.py b/pystemd/utils/unit.py new file mode 100644 index 0000000..b42eff8 --- /dev/null +++ b/pystemd/utils/unit.py @@ -0,0 +1,15 @@ +# +# Copyright (c) 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the LICENSE file in +# the root directory of this source tree. +# + +from __future__ import annotations + +import uuid + + +def random_unit_name(*, unit_type="service", prefix="pystemd"): + return f"{prefix}{uuid.uuid4().hex}.{unit_type}" diff --git a/pystemd/utils.py b/pystemd/utils/x2.py similarity index 92% rename from pystemd/utils.py rename to pystemd/utils/x2.py index 3fb5ff3..b1b6c11 100644 --- a/pystemd/utils.py +++ b/pystemd/utils/x2.py @@ -9,11 +9,8 @@ from __future__ import annotations import shlex -import uuid from pathlib import Path -from typing import Any, Iterable, TypeVar - -T = TypeVar("T") +from typing import Any, Iterable def x2char_star(what_to_convert: Any, convert_all: bool = False) -> bytes: @@ -115,13 +112,3 @@ def x2cmdlist( return [strlist2cmd(what_to_convert, cont)] return [strlist2cmd(_, cont) for _ in what_to_convert] - - -def unwrap(obj: T | None, msg="object was None") -> T: - if obj is None: - raise ValueError(msg) - return obj - - -def random_unit_name(*, unit_type="service", prefix="pystemd"): - return f"{prefix}{uuid.uuid4().hex}.{unit_type}" diff --git a/setup.py b/setup.py index eed81a3..d919582 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,13 @@ version="0.15.3", author="Alvaro Leiva Geisse", author_email="aleivag@gmail.com", - packages=["pystemd", "pystemd.systemd1", "pystemd.machine1", "pystemd.DBus"], + packages=[ + "pystemd", + "pystemd.systemd1", + "pystemd.machine1", + "pystemd.DBus", + "pystemd.utils", + ], ext_modules=external_modules, package_data={ "pystemd": [ From c80ed8e753ade28fadeaa7893535c5a0d279c7d4 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Thu, 29 Oct 2026 17:00:56 -0700 Subject: [PATCH 08/10] Add pystemd.utils.ns.join_namespaces, use it in futures join_namespaces(pid, namespaces=None) moves the calling process into the namespaces of `pid` (like `nsenter --target pid --all`) and returns the names it joined. `namespaces` is an iterable of names ("net", "mnt", ...; NAMESPACES lists them), all by default. - Namespaces already shared with `pid` are skipped (compared by inode under /proc/*/ns/), so asking for "all" works when we share e.g. the user namespace, which setns() would otherwise reject with EINVAL. - First tries a single setns() on a pidfd (Python >= 3.12, Linux >= 5.8), which joins everything atomically; otherwise (Python 3.11, older kernels) joins one namespace at a time in nsenter's order, closing each fd. - Failures raise OSError instead of being ignored. pystemd.futures.enter_unit() used to open every /proc//ns/* file, call setns() on it, ignore the result and never close the fd. It now calls join_namespaces(main_pid): no fd leak (one per namespace, in every worker), and a namespace that can't be joined (e.g. PrivateNetwork=) is an error rather than a worker that silently runs outside the sandbox. Tested on Linux 6.19 / Python 3.14: joining a PrivateNetwork= + ProtectHostnameEx= unit gives ['uts', 'net', 'mnt'] with only `lo` and the unit's hostname, through both the pidfd path and the forced fallback, with no fds leaked; futures.run() with User=nobody + PrivateNetwork, CPUQuota and MemoryMax behaves as before. --- CHANGES.md | 1 + pystemd/futures.py | 10 ++-- pystemd/utils/__init__.py | 2 + pystemd/utils/ns.py | 106 ++++++++++++++++++++++++++++++++++++++ tests/test_futures.py | 10 ++-- tests/test_ns.py | 34 ++++++++++++ 6 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 pystemd/utils/ns.py create mode 100644 tests/test_ns.py diff --git a/CHANGES.md b/CHANGES.md index 791d425..8481e38 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,6 +17,7 @@ dev * Add `fds=` to `pystemd.daemon.notify` (sd_pid_notify_with_fds) and add `pystemd.daemon.listen_fds_with_names` (file descriptor store / named socket activation) * Add `pystemd.daemon.store_fd` and `pystemd.daemon.store_fds` helpers for the file descriptor store * Split `pystemd.utils` into a package (`pystemd.utils.{x2,unit,typing}`); existing imports keep working +* Add `pystemd.utils.ns.join_namespaces` (atomic pidfd `setns`, with a per-namespace fallback); `pystemd.futures` uses it, which also fixes leaking one fd per namespace in every worker 0.15.3 ===== diff --git a/pystemd/futures.py b/pystemd/futures.py index 7d2639b..d0c45e3 100644 --- a/pystemd/futures.py +++ b/pystemd/futures.py @@ -4,15 +4,14 @@ from concurrent.futures import ProcessPoolExecutor from functools import cached_property from multiprocessing.context import BaseContext, ForkProcess -from pathlib import Path from typing import Any, Sequence, cast import psutil -import pystemd.cutils import pystemd.run import pystemd.utils from pystemd.dbuslib import DBus +from pystemd.utils.ns import join_namespaces class TransientUnitContext(BaseContext): @@ -78,7 +77,7 @@ def enter_unit(unit): after fork, but before code to run. It basically do: 1) Attach itself to the main transient unit (making it part of the cgroup) - 2) Change its namespace too all the namespaces of the main transient unit, this is best effort + 2) Join all the namespaces of the main transient unit (see pystemd.utils.ns.join_namespaces) 3) Change group and user to the group and user of the main transient unit """ # get the unit information @@ -90,9 +89,8 @@ def enter_unit(unit): unit.Service.AttachProcesses("/", [os.getpid()]) - # attach ourselves to the namespace - for ns in Path(f"/proc/{main_pid}/ns").iterdir(): - pystemd.cutils.setns(os.open(ns, os.O_RDONLY), 0) + # enter the unit's namespaces (PrivateNetwork=, PrivateTmp=, ...) + join_namespaces(main_pid) # change the user os.setgid(gid) diff --git a/pystemd/utils/__init__.py b/pystemd/utils/__init__.py index 4fd6143..eb2cd2a 100644 --- a/pystemd/utils/__init__.py +++ b/pystemd/utils/__init__.py @@ -12,12 +12,14 @@ x2char_star` (and friends) keeps working. """ +from pystemd.utils.ns import join_namespaces from pystemd.utils.typing import T, unwrap from pystemd.utils.unit import random_unit_name from pystemd.utils.x2 import str2cmd, strlist2cmd, x2char_star, x2cmdlist __all__ = [ "T", + "join_namespaces", "random_unit_name", "str2cmd", "strlist2cmd", diff --git a/pystemd/utils/ns.py b/pystemd/utils/ns.py new file mode 100644 index 0000000..4e11987 --- /dev/null +++ b/pystemd/utils/ns.py @@ -0,0 +1,106 @@ +# +# Copyright (c) 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the LICENSE file in +# the root directory of this source tree. +# + +""" +Linux namespaces helpers. +""" + +from __future__ import annotations + +import os +from typing import Iterable + +# CLONE_NEW* flags, defined here because os only has them from Python 3.12. +# The order is the one nsenter(1) joins them in: user first, mount late. +NAMESPACES = { + "user": 0x10000000, # CLONE_NEWUSER + "cgroup": 0x02000000, # CLONE_NEWCGROUP + "ipc": 0x08000000, # CLONE_NEWIPC + "uts": 0x04000000, # CLONE_NEWUTS + "net": 0x40000000, # CLONE_NEWNET + "pid": 0x20000000, # CLONE_NEWPID + "mnt": 0x00020000, # CLONE_NEWNS + "time": 0x00000080, # CLONE_NEWTIME +} + + +def join_namespaces(pid: int, namespaces: Iterable[str] | None = None) -> list[str]: + """ + Move the calling process into the namespaces of process `pid`, like + `nsenter --target pid --all`. Returns the names of the namespaces it + actually joined. + + `namespaces` is an iterable of names (keys of NAMESPACES, e.g. + ["net", "mnt"]); by default all of them. Namespaces we already share with + `pid` are skipped (re-joining your own user namespace is an error). + + Joining the pid namespace only affects children created afterwards, the + calling process keeps its pid. Needs the usual privileges (CAP_SYS_ADMIN, + or being in a parent user namespace of the target). + + Uses one atomic setns() on a pidfd (Python >= 3.12, Linux >= 5.8) and + falls back to joining one namespace at a time through /proc//ns/*. + Raises OSError if a namespace can't be joined. + """ + wanted = list(NAMESPACES) if namespaces is None else list(namespaces) + if unknown := set(wanted) - set(NAMESPACES): + raise ValueError( + f"unknown namespaces {sorted(unknown)}, expected some of {list(NAMESPACES)}" + ) + + to_join = [ + name + for name in NAMESPACES # keep nsenter's order + if name in wanted and _ns_id(pid, name) not in (None, _ns_id("self", name)) + ] + if not to_join: + return [] + + flags = 0 + for name in to_join: + flags |= NAMESPACES[name] + + try: + pidfd = os.pidfd_open(pid) + except (AttributeError, OSError): # Linux < 5.3 + pidfd = None + if pidfd is not None: + try: + _setns(pidfd, flags) # all at once, atomically + return to_join + except OSError: # Linux < 5.8 doesn't take pidfds in setns() + pass + finally: + os.close(pidfd) + + for name in to_join: + fd = os.open(f"/proc/{pid}/ns/{name}", os.O_RDONLY | os.O_CLOEXEC) + try: + _setns(fd, NAMESPACES[name]) + finally: + os.close(fd) + return to_join + + +def _ns_id(pid: int | str, name: str) -> int | None: + """The inode identifying a namespace, None if the kernel doesn't have it.""" + try: + return os.stat(f"/proc/{pid}/ns/{name}").st_ino + except FileNotFoundError: + return None + + +def _setns(fd: int, nstype: int) -> None: + if hasattr(os, "setns"): # Python >= 3.12 + os.setns(fd, nstype) + return + + import pystemd.cutils + + if pystemd.cutils.setns(fd, nstype) != 0: + raise OSError(f"setns(fd={fd}, nstype={nstype:#x}) failed") diff --git a/tests/test_futures.py b/tests/test_futures.py index 15b208e..7a0918c 100644 --- a/tests/test_futures.py +++ b/tests/test_futures.py @@ -38,17 +38,13 @@ def test_run(self, TransientUnitContext: MagicMock) -> None: @patch("os.setuid", autospec=True) @patch("os.setgid", autospec=True) -@patch("os.open", autospec=True) -@patch("pystemd.cutils.setns", autospec=True) +@patch.object(pystemd.futures, "join_namespaces", autospec=True) @patch.object(pystemd.futures.psutil, "Process", autospec=True) -@patch.object(pystemd.futures, "Path", autospec=True) class TestEnterUnit(unittest.TestCase): def test_enter( self, - Path: MagicMock, Process: MagicMock, - setns: MagicMock, - os_open: MagicMock, + join_namespaces: MagicMock, os_setgid: MagicMock, os_setuid: MagicMock, ) -> None: @@ -62,7 +58,7 @@ def test_enter( Process.assert_called_once_with(main_pid) unit.Service.AttachProcesses.assert_called_once_with("/", [os.getpid()]) - Path.assert_called_once_with(f"/proc/{main_pid}/ns") + join_namespaces.assert_called_once_with(main_pid) os_setgid.assert_called_once_with(gid) os_setuid.assert_called_once_with(uid) diff --git a/tests/test_ns.py b/tests/test_ns.py new file mode 100644 index 0000000..f06c5d2 --- /dev/null +++ b/tests/test_ns.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the LICENSE file in +# the root directory of this source tree. +# + +import os +from unittest import TestCase + +from pystemd.utils import join_namespaces as reexported +from pystemd.utils.ns import NAMESPACES, join_namespaces + + +class TestJoinNamespaces(TestCase): + def test_own_namespaces_is_a_noop(self): + # we share every namespace with ourselves: nothing to join, no privileges needed + self.assertEqual(join_namespaces(os.getpid()), []) + self.assertEqual(join_namespaces(os.getpid(), ["net", "mnt"]), []) + + def test_unknown_namespace(self): + with self.assertRaises(ValueError): + join_namespaces(os.getpid(), ["net", "nope"]) + + def test_names(self): + self.assertEqual( + set(NAMESPACES), + {"user", "cgroup", "ipc", "uts", "net", "pid", "mnt", "time"}, + ) + + def test_reexported(self): + self.assertIs(reexported, join_namespaces) From 27da744f551122eb74dae3708034228752440723 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Fri, 30 Oct 2026 13:29:28 -0700 Subject: [PATCH 09/10] Add delegate= to pystemd.run pystemd.run(cmd, delegate=True) sets Delegate= on the transient unit, a shortcut for extra={"Delegate": True}. Delegation is what lets Service.AttachProcesses move processes into the unit. None (the default) sends nothing and keeps systemd's default; extra= still overrides it. Adds tests/test_run.py, the first unit tests for pystemd.run, with the D-Bus side mocked. --- CHANGES.md | 1 + pystemd/run.py | 5 +++++ tests/test_run.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/test_run.py diff --git a/CHANGES.md b/CHANGES.md index 8481e38..5ef97e4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,7 @@ dev * Add `pystemd.daemon.store_fd` and `pystemd.daemon.store_fds` helpers for the file descriptor store * Split `pystemd.utils` into a package (`pystemd.utils.{x2,unit,typing}`); existing imports keep working * Add `pystemd.utils.ns.join_namespaces` (atomic pidfd `setns`, with a per-namespace fallback); `pystemd.futures` uses it, which also fixes leaking one fd per namespace in every worker +* Add `delegate=` to `pystemd.run`, a shortcut for `extra={"Delegate": ...}` 0.15.3 ===== diff --git a/pystemd/run.py b/pystemd/run.py index e9b9ab8..8730deb 100644 --- a/pystemd/run.py +++ b/pystemd/run.py @@ -83,6 +83,7 @@ def run( stop_post_cmd: Sequence[str | bytes] | str | bytes | None = None, start_pre_cmd: Sequence[str | bytes] | str | bytes | None = None, start_post_cmd: Sequence[str | bytes] | str | bytes | None = None, + delegate: bool | None = None, ) -> Unit: """ pystemd.run imitates systemd-run, but with a pythonic feel to it. @@ -146,6 +147,9 @@ def run( stderr: Specify a file descriptor for stderr. By default this is `None` and your unit will not have a stderr. slice_: the slice under you want to run the unit. + delegate: Set `Delegate=` on the unit. True hands the unit's cgroup + subtree to the service (needed for `AttachProcesses`), False turns + it off. None, the default, leaves systemd's default. More info and examples in: https://github.com/systemd/pystemd/blob/master/_docs/pystemd.run.md @@ -263,6 +267,7 @@ def bus_factory(): b"CollectMode": b"inactive-or-failed" if collect else None, b"WorkingDirectory": cwd, b"User": user, + b"Delegate": delegate, b"Nice": nice, b"RuntimeMaxUSec": runtime_max_usec, b"Environment": [ diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..ff4762a --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,40 @@ +#!/usr/bin/env fbpython +# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +import pystemd.run + +# pystemd.run is the function; the module it was defined in is kept here +run_module = pystemd.run.__module__ + + +@patch.object(run_module, "Unit", autospec=True) +@patch.object(run_module, "SDManager") +@patch.object(run_module, "DBus") +class TestRunDelegate(unittest.TestCase): + def unit_properties(self, SDManager: MagicMock, **kwargs: object) -> dict: + run_module.run(["/bin/true"], user_mode=False, **kwargs) + manager = SDManager.return_value.__enter__.return_value + (_name, _mode, properties), _ = manager.Manager.StartTransientUnit.call_args + return properties + + def test_delegate_default_is_not_sent(self, DBus, SDManager, Unit) -> None: + self.assertNotIn(b"Delegate", self.unit_properties(SDManager)) + + def test_delegate_true(self, DBus, SDManager, Unit) -> None: + self.assertIs(self.unit_properties(SDManager, delegate=True)[b"Delegate"], True) + + def test_delegate_false(self, DBus, SDManager, Unit) -> None: + self.assertIs( + self.unit_properties(SDManager, delegate=False)[b"Delegate"], False + ) + + def test_extra_still_wins(self, DBus, SDManager, Unit) -> None: + properties = self.unit_properties( + SDManager, delegate=True, extra={b"Delegate": False} + ) + self.assertIs(properties[b"Delegate"], False) From aff2d8c349d1bf511cfc080b38964a77d2b52329 Mon Sep 17 00:00:00 2001 From: Alvaro Leiva Geisse Date: Fri, 30 Oct 2026 14:44:53 -0700 Subject: [PATCH 10/10] Add OOMPolicy, OOMScoreAdjust and ManagedOOM* signatures Needed to set them from pystemd.run(extra={...}) (otherwise KeyError). Types as exposed by systemd 259 on org.freedesktop.systemd1.Service: OOMPolicy "s", OOMScoreAdjust "i", ManagedOOMSwap "s", ManagedOOMMemoryPressure "s", ManagedOOMMemoryPressureLimit "u", ManagedOOMMemoryPressureDurationUSec "t", ManagedOOMPreference "s". Tested: stress-ng --vm under MemoryMax=50M with OOMPolicy=continue and OOMScoreAdjust=500 keeps running while memory.events oom_kill climbs; with the default policy (stop) the unit fails with result oom-kill. --- CHANGES.md | 1 + pystemd/systemd1/unit_signatures.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 5ef97e4..d85fcba 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,6 +19,7 @@ dev * Split `pystemd.utils` into a package (`pystemd.utils.{x2,unit,typing}`); existing imports keep working * Add `pystemd.utils.ns.join_namespaces` (atomic pidfd `setns`, with a per-namespace fallback); `pystemd.futures` uses it, which also fixes leaking one fd per namespace in every worker * Add `delegate=` to `pystemd.run`, a shortcut for `extra={"Delegate": ...}` +* Add `OOMPolicy`, `OOMScoreAdjust` and the `ManagedOOM*` properties to systemd1 KNOWN_UNIT_SIGNATURES 0.15.3 ===== diff --git a/pystemd/systemd1/unit_signatures.py b/pystemd/systemd1/unit_signatures.py index 1db504e..5362d0e 100644 --- a/pystemd/systemd1/unit_signatures.py +++ b/pystemd/systemd1/unit_signatures.py @@ -238,6 +238,13 @@ b"MemoryMaxScale": b"u", b"MemorySwapMax": b"t", b"MemorySwapMaxScale": b"u", + b"OOMPolicy": b"s", + b"OOMScoreAdjust": b"i", + b"ManagedOOMSwap": b"s", + b"ManagedOOMMemoryPressure": b"s", + b"ManagedOOMMemoryPressureLimit": b"u", + b"ManagedOOMMemoryPressureDurationUSec": b"t", + b"ManagedOOMPreference": b"s", b"MemoryLimit": b"t", b"MemoryLimitScale": b"u", b"IOAccounting": b"b",