pidfd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pidfd/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Python bindings for Linux pidfds.
3
+
4
+ A pidfd is a file descriptor referring to a process. It is a stable
5
+ handle: signaling through it can never hit a recycled pid, it becomes
6
+ pollable-readable when the task exits, and it can be waited on with
7
+ waitid(2) or used to duplicate the task's file descriptors with
8
+ pidfd_getfd(2). Everything goes through raw syscalls via ctypes. There
9
+ are no runtime dependencies.
10
+
11
+ Kernel references: pidfd_open(2), pidfd_send_signal(2), pidfd_getfd(2),
12
+ waitid(2).
13
+ """
14
+
15
+ from .errors import PidFdError, UnsupportedError
16
+ from .pidfd import (
17
+ OpenFlag,
18
+ PidFd,
19
+ SigInfo,
20
+ SignalFlag,
21
+ WaitCode,
22
+ WaitFlag,
23
+ WaitResult,
24
+ pidfd_send_signal,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "OpenFlag",
31
+ "PidFd",
32
+ "PidFdError",
33
+ "SigInfo",
34
+ "SignalFlag",
35
+ "UnsupportedError",
36
+ "WaitCode",
37
+ "WaitFlag",
38
+ "WaitResult",
39
+ "__version__",
40
+ "pidfd_send_signal",
41
+ ]
pidfd/_syscall.py ADDED
@@ -0,0 +1,163 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Raw ctypes bindings for the pidfd syscalls and waitid(2).
3
+
4
+ glibc has no wrappers for pidfd_open, pidfd_send_signal or pidfd_getfd,
5
+ so all four calls go through libc's syscall(2). The numbers differ per
6
+ architecture for waitid only. The three pidfd calls share the common
7
+ numbering on every supported arch.
8
+
9
+ Kernel references: pidfd_open(2), pidfd_send_signal(2), pidfd_getfd(2),
10
+ waitid(2), asm/unistd*.h, asm-generic/unistd.h.
11
+ """
12
+
13
+ import ctypes
14
+ import ctypes.util
15
+ import errno
16
+ import os
17
+ import platform
18
+ import sys
19
+ from typing import NoReturn
20
+
21
+ from .errors import PidFdError, UnsupportedError
22
+
23
+ P_PIDFD = 3
24
+
25
+
26
+ class _SigChld(ctypes.Structure):
27
+ """The _sifields._sigchld member of the kernel siginfo_t."""
28
+
29
+ _fields_ = [
30
+ ("si_pid", ctypes.c_int32),
31
+ ("si_uid", ctypes.c_uint32),
32
+ ("si_status", ctypes.c_int32),
33
+ ("si_utime", ctypes.c_long),
34
+ ("si_stime", ctypes.c_long),
35
+ ]
36
+
37
+
38
+ class _SiFields(ctypes.Union):
39
+ """union __sifields, padded so the whole siginfo stays writable."""
40
+
41
+ _fields_ = [
42
+ ("sigchld", _SigChld),
43
+ ("pad", ctypes.c_byte * 128),
44
+ ]
45
+
46
+
47
+ class SigInfo(ctypes.Structure):
48
+ """The kernel siginfo_t used by waitid(2) and pidfd_send_signal(2).
49
+
50
+ Only the SIGCHLD member of union __sifields is decoded. The rest is
51
+ padding. The buffer is deliberately larger than the kernel's
52
+ 128-byte siginfo_t so waitid's user access check always succeeds.
53
+ """
54
+
55
+ _fields_ = [
56
+ ("si_signo", ctypes.c_int),
57
+ ("si_errno", ctypes.c_int),
58
+ ("si_code", ctypes.c_int),
59
+ ("_sifields", _SiFields),
60
+ ]
61
+
62
+ @property
63
+ def si_pid(self) -> int:
64
+ return int(self._sifields.sigchld.si_pid)
65
+
66
+ @si_pid.setter
67
+ def si_pid(self, value: int) -> None:
68
+ self._sifields.sigchld.si_pid = value
69
+
70
+ @property
71
+ def si_uid(self) -> int:
72
+ return int(self._sifields.sigchld.si_uid)
73
+
74
+ @si_uid.setter
75
+ def si_uid(self, value: int) -> None:
76
+ self._sifields.sigchld.si_uid = value
77
+
78
+ @property
79
+ def si_status(self) -> int:
80
+ return int(self._sifields.sigchld.si_status)
81
+
82
+ @si_status.setter
83
+ def si_status(self, value: int) -> None:
84
+ self._sifields.sigchld.si_status = value
85
+
86
+
87
+ _libc: ctypes.CDLL | None = None
88
+ _numbers: tuple[int, int, int, int] | None = None
89
+
90
+
91
+ def _get_libc() -> ctypes.CDLL:
92
+ global _libc
93
+ if _libc is None:
94
+ if sys.platform != "linux":
95
+ raise UnsupportedError("pidfds are only available on Linux")
96
+ name = ctypes.util.find_library("c")
97
+ _libc = ctypes.CDLL(name or None, use_errno=True)
98
+ _libc.syscall.restype = ctypes.c_long
99
+ return _libc
100
+
101
+
102
+ def _syscall_numbers() -> tuple[int, int, int, int]:
103
+ """Return (pidfd_open, pidfd_send_signal, pidfd_getfd, waitid)."""
104
+ global _numbers
105
+ if _numbers is not None:
106
+ return _numbers
107
+ machine = platform.machine().lower()
108
+ if machine in ("x86_64", "amd64"):
109
+ if ctypes.sizeof(ctypes.c_void_p) == 4:
110
+ # The x32 ABI carries different numbers and a differently
111
+ # aligned siginfo_t. Reject it rather than call the wrong
112
+ # syscall.
113
+ raise UnsupportedError("the x32 ABI is not supported")
114
+ _numbers = (434, 424, 438, 247)
115
+ elif machine in ("i386", "i486", "i586", "i686", "x86"):
116
+ _numbers = (434, 424, 438, 284)
117
+ elif machine in ("aarch64", "arm64", "riscv32", "riscv64", "loongarch64"):
118
+ _numbers = (434, 424, 438, 95)
119
+ elif machine in ("armv6l", "armv7l", "arm"):
120
+ _numbers = (434, 424, 438, 280)
121
+ elif machine.startswith("ppc"):
122
+ _numbers = (434, 424, 438, 272)
123
+ elif machine.startswith("s390"):
124
+ _numbers = (434, 424, 438, 281)
125
+ else:
126
+ raise UnsupportedError(f"no pidfd syscall numbers for architecture {machine}")
127
+ return _numbers
128
+
129
+
130
+ def _raise_errno(err: int) -> NoReturn:
131
+ if err == errno.ENOSYS:
132
+ raise UnsupportedError(err, os.strerror(err))
133
+ raise PidFdError(err, os.strerror(err))
134
+
135
+
136
+ def _call(nr: int, *args: object) -> int:
137
+ ret = int(_get_libc().syscall(nr, *args))
138
+ if ret == -1:
139
+ _raise_errno(ctypes.get_errno())
140
+ return ret
141
+
142
+
143
+ def pidfd_open(pid: int, flags: int = 0) -> int:
144
+ """Open a pidfd for pid and return the new file descriptor."""
145
+ return _call(_syscall_numbers()[0], pid, flags)
146
+
147
+
148
+ def pidfd_send_signal(
149
+ pidfd: int, sig: int, info: SigInfo | None, flags: int = 0
150
+ ) -> None:
151
+ """Send sig to the process the pidfd refers to."""
152
+ ref = ctypes.byref(info) if info is not None else None
153
+ _call(_syscall_numbers()[1], pidfd, sig, ref, flags)
154
+
155
+
156
+ def pidfd_getfd(pidfd: int, targetfd: int, flags: int = 0) -> int:
157
+ """Duplicate targetfd from the pidfd's process into our fd table."""
158
+ return _call(_syscall_numbers()[2], pidfd, targetfd, flags)
159
+
160
+
161
+ def waitid(idtype: int, upid: int, info: SigInfo, options: int) -> int:
162
+ """waitid(2) filling info; the rusage argument stays NULL."""
163
+ return _call(_syscall_numbers()[3], idtype, upid, ctypes.byref(info), options, 0)
pidfd/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Exception types raised by pidfd."""
3
+
4
+
5
+ class PidFdError(OSError):
6
+ """A pidfd syscall failed."""
7
+
8
+
9
+ class UnsupportedError(PidFdError):
10
+ """The running kernel or architecture lacks the requested pidfd feature."""
pidfd/pidfd.py ADDED
@@ -0,0 +1,222 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """PidFd: a file descriptor that refers to a process.
3
+
4
+ A pidfd is a stable reference to a task: unlike a raw pid it cannot be
5
+ recycled, so signaling through it cannot hit an unrelated process. The
6
+ descriptor becomes readable (POLLIN) once the task exits and turns into
7
+ a zombie, and reports POLLHUP once it is reaped. waitid(2) with idtype
8
+ P_PIDFD reaps a child through it.
9
+
10
+ Kernel references: pidfd_open(2), pidfd_send_signal(2), pidfd_getfd(2),
11
+ waitid(2), linux/pidfd.h.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import os
18
+ from dataclasses import dataclass
19
+ from enum import IntEnum, IntFlag
20
+ from types import TracebackType
21
+
22
+ from . import _syscall
23
+ from ._syscall import SigInfo
24
+
25
+ __all__ = [
26
+ "OpenFlag",
27
+ "PidFd",
28
+ "SigInfo",
29
+ "SignalFlag",
30
+ "WaitCode",
31
+ "WaitFlag",
32
+ "WaitResult",
33
+ "pidfd_send_signal",
34
+ ]
35
+
36
+
37
+ class OpenFlag(IntFlag):
38
+ """Flags for pidfd_open(2), mirroring linux/pidfd.h.
39
+
40
+ NONBLOCK makes wait() fail with EAGAIN instead of blocking while the
41
+ task is still running (Linux 5.10+). THREAD pins the fd to a single
42
+ thread instead of the thread-group leader (Linux 6.9+).
43
+ """
44
+
45
+ NONBLOCK = 0o4000 # O_NONBLOCK
46
+ THREAD = 0o200 # O_EXCL
47
+
48
+
49
+ class SignalFlag(IntFlag):
50
+ """Scope flags for pidfd_send_signal(2), Linux 6.9+."""
51
+
52
+ THREAD = 1 << 0
53
+ THREAD_GROUP = 1 << 1
54
+ PROCESS_GROUP = 1 << 2
55
+
56
+
57
+ class WaitFlag(IntFlag):
58
+ """Option bits for waitid(2), mirroring linux/wait.h.
59
+
60
+ At least one of WEXITED, WSTOPPED or WCONTINUED is required.
61
+ """
62
+
63
+ WNOHANG = 0x00000001
64
+ WSTOPPED = 0x00000002
65
+ WEXITED = 0x00000004
66
+ WCONTINUED = 0x00000008
67
+ WNOWAIT = 0x01000000
68
+
69
+
70
+ class WaitCode(IntEnum):
71
+ """si_code values the kernel reports in a SIGCHLD siginfo (CLD_*)."""
72
+
73
+ CLD_EXITED = 1
74
+ CLD_KILLED = 2
75
+ CLD_DUMPED = 3
76
+ CLD_TRAPPED = 4
77
+ CLD_STOPPED = 5
78
+ CLD_CONTINUED = 6
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class WaitResult:
83
+ """The fields waitid(2) fills into the siginfo_t for a child.
84
+
85
+ code holds a WaitCode (CLD_*) value. Its meaning for status is that
86
+ of waitpid(2): an exit status when code is CLD_EXITED, a signal
87
+ number for CLD_KILLED/CLD_DUMPED/CLD_STOPPED/CLD_CONTINUED. A result
88
+ with signo 0 means the call returned without an event, which only
89
+ happens when WNOHANG was requested.
90
+ """
91
+
92
+ pid: int
93
+ uid: int
94
+ signo: int
95
+ code: int
96
+ status: int
97
+
98
+
99
+ class PidFd:
100
+ """A pidfd file descriptor referring to a process.
101
+
102
+ Obtain one with open() or wrap an existing descriptor in the
103
+ constructor. Instances own their fd: close() is idempotent and the
104
+ context manager closes on exit.
105
+ """
106
+
107
+ def __init__(self, fd: int) -> None:
108
+ self._fd = -1 # keeps __del__ safe when validation fails
109
+ if fd < 0:
110
+ raise ValueError(f"fd must not be negative: {fd}")
111
+ self._fd = fd
112
+
113
+ @classmethod
114
+ def open(cls, pid: int, flags: OpenFlag | int = 0) -> PidFd:
115
+ """Open a pidfd for pid via pidfd_open(2) (Linux 5.3+)."""
116
+ return cls(_syscall.pidfd_open(int(pid), int(flags)))
117
+
118
+ def fileno(self) -> int:
119
+ """Return the underlying descriptor, or -1 once closed."""
120
+ return self._fd
121
+
122
+ @property
123
+ def closed(self) -> bool:
124
+ """Whether close() has released the descriptor."""
125
+ return self._fd < 0
126
+
127
+ def _check_open(self) -> int:
128
+ if self._fd < 0:
129
+ raise ValueError("pidfd is closed")
130
+ return self._fd
131
+
132
+ def close(self) -> None:
133
+ """Close the descriptor. Safe to call more than once."""
134
+ if self._fd >= 0:
135
+ fd, self._fd = self._fd, -1
136
+ os.close(fd)
137
+
138
+ def detach(self) -> int:
139
+ """Release the descriptor without closing it.
140
+
141
+ Returns the raw fd and marks the PidFd closed, transferring
142
+ ownership of the descriptor to the caller.
143
+ """
144
+ fd = self._check_open()
145
+ self._fd = -1
146
+ return fd
147
+
148
+ def signal(
149
+ self,
150
+ sig: int,
151
+ siginfo: SigInfo | None = None,
152
+ flags: SignalFlag | int = 0,
153
+ ) -> None:
154
+ """Send sig to the referenced process via pidfd_send_signal(2).
155
+
156
+ siginfo=None selects the same implicit fields kill(2) supplies.
157
+ Pass a populated SigInfo for rt_sigqueueinfo(2)-style delivery,
158
+ where si_code must be a negative SI_* value such as SI_QUEUE
159
+ when the target is not the caller. flags accepts the SignalFlag
160
+ bits on Linux 6.9+. 0 is the only value older kernels allow.
161
+ ESRCH means the task is already gone
162
+ and reaped, never that the pid was recycled.
163
+ """
164
+ _syscall.pidfd_send_signal(self._check_open(), int(sig), siginfo, int(flags))
165
+
166
+ def wait(self, flags: WaitFlag | int = WaitFlag.WEXITED) -> WaitResult:
167
+ """waitid(P_PIDFD) on the referenced child (Linux 5.4+).
168
+
169
+ The pidfd must refer to a child of the caller, otherwise the
170
+ kernel answers ECHILD. A pidfd opened with OpenFlag.NONBLOCK
171
+ fails with EAGAIN while the child is still running. On a
172
+ blocking pidfd add WaitFlag.WNOHANG for a poll-style call that
173
+ returns a WaitResult with signo 0.
174
+ """
175
+ info = SigInfo()
176
+ _syscall.waitid(_syscall.P_PIDFD, self._check_open(), info, int(flags))
177
+ return WaitResult(
178
+ pid=info.si_pid,
179
+ uid=info.si_uid,
180
+ signo=info.si_signo,
181
+ code=info.si_code,
182
+ status=info.si_status,
183
+ )
184
+
185
+ def getfd(self, targetfd: int, flags: int = 0) -> int:
186
+ """Duplicate targetfd of the referenced process into our fd table.
187
+
188
+ The returned fd shares the open file description with the
189
+ target's fd and has close-on-exec set. Access is governed by a
190
+ PTRACE_MODE_ATTACH_REALCREDS ptrace check (see ptrace(2)): under
191
+ yama ptrace_scope that typically means the target must be a
192
+ descendant, or the caller needs CAP_SYS_PTRACE.
193
+ """
194
+ return _syscall.pidfd_getfd(self._check_open(), int(targetfd), int(flags))
195
+
196
+ def __enter__(self) -> PidFd:
197
+ return self
198
+
199
+ def __exit__(
200
+ self,
201
+ exc_type: type[BaseException] | None,
202
+ exc_value: BaseException | None,
203
+ traceback: TracebackType | None,
204
+ ) -> None:
205
+ self.close()
206
+
207
+ def __index__(self) -> int:
208
+ return self._check_open()
209
+
210
+ def __repr__(self) -> str:
211
+ state = self._fd if self._fd >= 0 else "closed"
212
+ return f"PidFd(fd={state})"
213
+
214
+ def __del__(self) -> None:
215
+ with contextlib.suppress(OSError):
216
+ self.close()
217
+
218
+
219
+ def pidfd_send_signal(fd: int | PidFd, sig: int, flags: SignalFlag | int = 0) -> None:
220
+ """Send sig through a pidfd without wrapping it in PidFd first."""
221
+ raw = fd.fileno() if isinstance(fd, PidFd) else int(fd)
222
+ _syscall.pidfd_send_signal(raw, int(sig), None, int(flags))
pidfd/py.typed ADDED
File without changes
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: pidfd
3
+ Version: 0.1.0
4
+ Summary: Python bindings for Linux pidfds
5
+ Project-URL: Homepage, https://quad4.io
6
+ Project-URL: Repository, https://github.com/Quad4-Software/pidfd
7
+ Project-URL: Issues, https://github.com/Quad4-Software/pidfd/issues
8
+ Project-URL: Changelog, https://github.com/Quad4-Software/pidfd/blob/master/CHANGELOG.md
9
+ Author: Quad4
10
+ License-Expression: 0BSD
11
+ License-File: LICENSE
12
+ Keywords: linux,pidfd,process,signals
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: BSD License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: System :: Operating System Kernels :: Linux
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # pidfd
30
+
31
+ [![CI](https://github.com/Quad4-Software/pidfd/actions/workflows/ci.yml/badge.svg)](https://github.com/Quad4-Software/pidfd/actions/workflows/ci.yml)
32
+ [![CodeQL](https://github.com/Quad4-Software/pidfd/actions/workflows/codeql.yml/badge.svg)](https://github.com/Quad4-Software/pidfd/actions/workflows/codeql.yml)
33
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Quad4-Software/pidfd/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/pidfd)
34
+ [![PyPI](https://img.shields.io/pypi/v/pidfd.svg)](https://pypi.org/project/pidfd/)
35
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](LICENSE)
36
+
37
+ Dependency-free Python bindings for Linux pidfds. A pidfd is a file
38
+ descriptor referring to a process: a stable handle that can never hit a
39
+ recycled pid, becomes readable when the task exits, can be waited on
40
+ with waitid(2), and can duplicate the task's file descriptors with
41
+ pidfd_getfd(2). Everything goes through pidfd_open(2),
42
+ pidfd_send_signal(2), pidfd_getfd(2) and waitid(2) via ctypes. There
43
+ are no runtime dependencies.
44
+
45
+ Requires Python 3.10+ and Linux.
46
+
47
+ ## Install
48
+
49
+ pip install pidfd
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ import select, signal, subprocess
55
+ from pidfd import PidFd, WaitCode
56
+
57
+ proc = subprocess.Popen(["sleep", "30"])
58
+ with PidFd.open(proc.pid) as pf:
59
+ # poll(2) reports the fd readable once the task is a zombie
60
+ pf.signal(signal.SIGKILL)
61
+
62
+ poller = select.poll()
63
+ poller.register(pf.fileno(), select.POLLIN)
64
+ poller.poll()
65
+
66
+ result = pf.wait() # waitid(P_PIDFD)
67
+ assert result.code == WaitCode.CLD_KILLED
68
+ ```
69
+
70
+ `PidFd.open(pid, OpenFlag.NONBLOCK)` makes `wait()` fail with EAGAIN
71
+ instead of blocking. `pf.getfd(fd)` duplicates one of the target's open
72
+ fds (ptrace access rules apply).
73
+
74
+ ## Development
75
+
76
+ uv sync --group dev
77
+ make check
78
+
79
+ License: 0BSD. Quad4 Software, https://quad4.io
@@ -0,0 +1,9 @@
1
+ pidfd/__init__.py,sha256=7QQYwDE6IBcBBRbLIdsQo9POpSfTzgf19H52Qk200HU,958
2
+ pidfd/_syscall.py,sha256=m8jK6DrmLy7uqN7W2dLir1o441Z3NaHeHwH1QjAy3o8,5071
3
+ pidfd/errors.py,sha256=aKqnr7V60Y-DDLsYzMQfBvPH_Xkh56F9PmL-lPqBcUA,252
4
+ pidfd/pidfd.py,sha256=l7GTn4L88B6-R2jonBLuYdA-uZo0QSu-qrZLUb-g7CE,6892
5
+ pidfd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pidfd-0.1.0.dist-info/METADATA,sha256=D6SZpssZhfYI8bfuzjTTjGXBeWV-j_vp_YTYxEBUEPw,3015
7
+ pidfd-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ pidfd-0.1.0.dist-info/licenses/LICENSE,sha256=3Hnwsz5EXuTC9iTlGjzA171dKQtIVsgjFoF5DEMRl48,633
9
+ pidfd-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,12 @@
1
+ Copyright (c) 2026 Quad4
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose
4
+ with or without fee is hereby granted.
5
+
6
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
7
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
8
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
9
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
10
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
11
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
12
+ PERFORMANCE OF THIS SOFTWARE.