ppkit 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.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/lib/proc.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Processes: reading them from /proc (Linux, no psutil/lsof/ss needed), starting them, and signalling them.
|
|
2
|
+
|
|
3
|
+
Signalling a process, starting one and judging one alive by its pid go through these, and nothing else in pp
|
|
4
|
+
does them (tests/test_constructs.py reads the source and fails on a call that does):
|
|
5
|
+
|
|
6
|
+
Handle, signal_pid, signal_group, signal_and_wait, raise_to_self the only way pp signals anything
|
|
7
|
+
pidfd, pidfd_signal the fd one of those signals goes through
|
|
8
|
+
spawn, run, git, git_child, exec_replace the only way pp starts a process
|
|
9
|
+
alive whether a pid is a live process
|
|
10
|
+
|
|
11
|
+
The rest are helpers, which any caller may use: `say_ready`, how a child of spawn(ready=True) says its own
|
|
12
|
+
setup is done; `read`, `processes`, `ancestors`, `descendants`, `members` and `uptime`, what /proc says about
|
|
13
|
+
a process; and `sockets`, `socket_owners` and `port_owners`, what it says about who holds a port.
|
|
14
|
+
|
|
15
|
+
The one rule that motivates the reading: a pattern match must never include the caller's own ancestry. `pkill -f
|
|
16
|
+
PAT` run from a shell matches that shell, because PAT is in its command line. ancestors() is the set every
|
|
17
|
+
pattern search here excludes.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import select
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
28
|
+
|
|
29
|
+
from pp.lib import session
|
|
30
|
+
|
|
31
|
+
CLK_TCK = os.sysconf("SC_CLK_TCK")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Proc:
|
|
36
|
+
pid: int
|
|
37
|
+
ppid: int
|
|
38
|
+
pgid: int # its process group: the pid of the group's leader
|
|
39
|
+
comm: str
|
|
40
|
+
cmdline: list
|
|
41
|
+
uid: int
|
|
42
|
+
state: str
|
|
43
|
+
start: int # starttime in clock ticks since boot; with pid, a process identity that survives pid reuse
|
|
44
|
+
rss_kb: int
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def cmd(self) -> str:
|
|
48
|
+
return " ".join(self.cmdline) if self.cmdline else f"[{self.comm}]"
|
|
49
|
+
|
|
50
|
+
def age(self) -> float:
|
|
51
|
+
return uptime() - self.start / CLK_TCK
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def uptime() -> float:
|
|
55
|
+
with open("/proc/uptime") as f:
|
|
56
|
+
return float(f.read().split()[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_BOOT: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def boot_id() -> str:
|
|
63
|
+
"""This boot, as the kernel names it (/proc/sys/kernel/random/boot_id).
|
|
64
|
+
|
|
65
|
+
A pid and the start time read beside it name a process within one boot and no further, so whatever keeps
|
|
66
|
+
them past the life of the process that read them keeps this too. Read once; "" where the kernel does not
|
|
67
|
+
say, which is then the same for everything this process records."""
|
|
68
|
+
global _BOOT
|
|
69
|
+
if _BOOT is None:
|
|
70
|
+
try:
|
|
71
|
+
with open("/proc/sys/kernel/random/boot_id") as f:
|
|
72
|
+
_BOOT = f.read().strip()
|
|
73
|
+
except OSError:
|
|
74
|
+
_BOOT = ""
|
|
75
|
+
return _BOOT
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _stat(pid: int) -> tuple[str, list[str]] | None:
|
|
79
|
+
"""/proc/<pid>/stat as (comm, the fields after it), or None when there is no such process. The one parser of
|
|
80
|
+
that line: comm is parenthesised and may itself hold spaces and parens, so it is cut at the LAST ')'."""
|
|
81
|
+
try:
|
|
82
|
+
with open(f"/proc/{pid}/stat", "rb") as f:
|
|
83
|
+
stat = f.read().decode(errors="replace")
|
|
84
|
+
lp, rp = stat.index("("), stat.rindex(")")
|
|
85
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError, NotADirectoryError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
return stat[lp + 1:rp], stat[rp + 2:].split()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# the fields of /proc/<pid>/stat after comm, counted as `man 5 proc` numbers them: state is field 3
|
|
91
|
+
_STATE, _PPID, _PGID, _START, _RSS = 0, 1, 2, 19, 21
|
|
92
|
+
DEAD = ("Z", "X") # a zombie or a reaped process: a pid, no longer a process
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read(pid: int) -> Proc | None:
|
|
96
|
+
got = _stat(pid)
|
|
97
|
+
if got is None:
|
|
98
|
+
return None
|
|
99
|
+
comm, rest = got
|
|
100
|
+
try:
|
|
101
|
+
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
|
102
|
+
raw = f.read()
|
|
103
|
+
uid = os.stat(f"/proc/{pid}").st_uid
|
|
104
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError, NotADirectoryError):
|
|
105
|
+
return None
|
|
106
|
+
page_kb = os.sysconf("SC_PAGE_SIZE") // 1024
|
|
107
|
+
cmdline = [a.decode(errors="replace") for a in raw.split(b"\0") if a] if raw else []
|
|
108
|
+
return Proc(pid=pid, ppid=int(rest[_PPID]), pgid=int(rest[_PGID]), comm=comm, cmdline=cmdline, uid=uid,
|
|
109
|
+
state=rest[_STATE], start=int(rest[_START]), rss_kb=int(rest[_RSS]) * page_kb)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def processes() -> dict[int, Proc]:
|
|
113
|
+
out = {}
|
|
114
|
+
for name in os.listdir("/proc"):
|
|
115
|
+
if name.isdigit():
|
|
116
|
+
p = read(int(name))
|
|
117
|
+
if p:
|
|
118
|
+
out[p.pid] = p
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ancestors(pid: int | None = None, procs: dict[int, Proc] | None = None) -> list[int]:
|
|
123
|
+
"""pid (default: this process) and every ancestor up to init."""
|
|
124
|
+
pid = os.getpid() if pid is None else pid
|
|
125
|
+
chain = []
|
|
126
|
+
while pid > 0 and pid not in chain:
|
|
127
|
+
chain.append(pid)
|
|
128
|
+
p = procs.get(pid) if procs is not None else read(pid)
|
|
129
|
+
if not p:
|
|
130
|
+
break
|
|
131
|
+
pid = p.ppid
|
|
132
|
+
return chain
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def descendants(pid: int, procs: dict[int, Proc] | None = None) -> list[int]:
|
|
136
|
+
"""Every descendant of pid (not pid itself), parents before children."""
|
|
137
|
+
procs = procs if procs is not None else processes()
|
|
138
|
+
kids: dict[int, list[int]] = {}
|
|
139
|
+
for p in procs.values():
|
|
140
|
+
kids.setdefault(p.ppid, []).append(p.pid)
|
|
141
|
+
out, todo = [], list(kids.get(pid, []))
|
|
142
|
+
while todo:
|
|
143
|
+
q = todo.pop(0)
|
|
144
|
+
if q in out:
|
|
145
|
+
continue
|
|
146
|
+
out.append(q)
|
|
147
|
+
todo.extend(kids.get(q, []))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def alive(pid: int, start: int | None = None) -> bool:
|
|
152
|
+
"""pid exists, is not a zombie, and (when start is given) is still the same process."""
|
|
153
|
+
p = read(pid)
|
|
154
|
+
if not p or p.state in DEAD:
|
|
155
|
+
return False
|
|
156
|
+
return start is None or p.start == start
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def members(pgid: int) -> list[int]:
|
|
160
|
+
"""The pids in process group pgid that are still running. A zombie is not one of them: it holds a pid, and no
|
|
161
|
+
signal reaches it."""
|
|
162
|
+
out = []
|
|
163
|
+
for name in os.listdir("/proc"):
|
|
164
|
+
if not name.isdigit():
|
|
165
|
+
continue
|
|
166
|
+
got = _stat(int(name))
|
|
167
|
+
if got and int(got[1][_PGID]) == pgid and got[1][_STATE] not in DEAD:
|
|
168
|
+
out.append(int(name))
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---- sockets --------------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
_TCP_LISTEN = "0A"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _hex_addr(s: str) -> tuple[str, int]:
|
|
178
|
+
host, port = s.split(":")
|
|
179
|
+
if len(host) == 8:
|
|
180
|
+
return socket.inet_ntop(socket.AF_INET, bytes.fromhex(host)[::-1]), int(port, 16)
|
|
181
|
+
# IPv6: four 32-bit words, each in host (little-endian) byte order
|
|
182
|
+
raw = b"".join(bytes.fromhex(host[i:i + 8])[::-1] for i in range(0, 32, 8))
|
|
183
|
+
return socket.inet_ntop(socket.AF_INET6, raw), int(port, 16)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def sockets(port: int | None = None, listening_only: bool = True) -> list[dict]:
|
|
187
|
+
"""Sockets from /proc/net/{tcp,tcp6,udp,udp6}: [{proto, addr, port, inode}]. TCP: LISTEN only unless asked."""
|
|
188
|
+
out = []
|
|
189
|
+
for proto in ("tcp", "tcp6", "udp", "udp6"):
|
|
190
|
+
try:
|
|
191
|
+
with open(f"/proc/net/{proto}") as f:
|
|
192
|
+
lines = f.read().splitlines()[1:]
|
|
193
|
+
except OSError:
|
|
194
|
+
continue
|
|
195
|
+
for line in lines:
|
|
196
|
+
parts = line.split()
|
|
197
|
+
addr, lport = _hex_addr(parts[1])
|
|
198
|
+
if port is not None and lport != port:
|
|
199
|
+
continue
|
|
200
|
+
if proto.startswith("tcp") and listening_only and parts[3] != _TCP_LISTEN:
|
|
201
|
+
continue
|
|
202
|
+
if proto.startswith("udp") and listening_only and int(parts[2].split(":")[1], 16) != 0:
|
|
203
|
+
continue # a connected udp socket (remote port set) is a client, not a listener
|
|
204
|
+
out.append({"proto": proto, "addr": addr, "port": lport, "inode": int(parts[9])})
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def socket_owners(inodes: set[int]) -> dict[int, set[int]]:
|
|
209
|
+
"""inode -> pids holding it. Only processes we may read /proc/<pid>/fd of (our own, unless root)."""
|
|
210
|
+
want = {f"socket:[{i}]": i for i in inodes}
|
|
211
|
+
out: dict[int, set[int]] = {}
|
|
212
|
+
if not want:
|
|
213
|
+
return out
|
|
214
|
+
for name in os.listdir("/proc"):
|
|
215
|
+
if not name.isdigit():
|
|
216
|
+
continue
|
|
217
|
+
fd_dir = f"/proc/{name}/fd"
|
|
218
|
+
try:
|
|
219
|
+
fds = os.listdir(fd_dir)
|
|
220
|
+
except OSError:
|
|
221
|
+
continue
|
|
222
|
+
for fd in fds:
|
|
223
|
+
try:
|
|
224
|
+
target = os.readlink(f"{fd_dir}/{fd}")
|
|
225
|
+
except OSError:
|
|
226
|
+
continue
|
|
227
|
+
if target in want:
|
|
228
|
+
out.setdefault(want[target], set()).add(int(name))
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def port_owners(port: int) -> tuple[list[dict], set[int]]:
|
|
233
|
+
"""(listening sockets on port, pids holding them). Sockets with no visible pid belong to another user."""
|
|
234
|
+
socks = sockets(port)
|
|
235
|
+
owners = socket_owners({s["inode"] for s in socks})
|
|
236
|
+
for s in socks:
|
|
237
|
+
s["pids"] = sorted(owners.get(s["inode"], set()))
|
|
238
|
+
return socks, {p for s in socks for p in s["pids"]}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---- signalling -----------------------------------------------------------------------------------
|
|
242
|
+
#
|
|
243
|
+
# Every signal pp sends goes through a handle, and nothing else in pp calls kill, killpg or pidfd_send_signal
|
|
244
|
+
# (tests/test_constructs.py keeps it that way). A pid on its own does not name a process: the kernel hands it out
|
|
245
|
+
# again once the process it named is reaped, so a pid read a moment ago can belong to something else by the time a
|
|
246
|
+
# signal is sent. A handle carries the start time read with the pid, and a signal goes out only while both match.
|
|
247
|
+
|
|
248
|
+
_LOOK = 0.05 # between looks, while waiting for a target to end
|
|
249
|
+
_KILL_WAIT = 2.0 # how long a SIGKILL is given before its target is reported alive
|
|
250
|
+
_WRAPPED = hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal") # this Python wraps the calls
|
|
251
|
+
_PIDFD_OPEN, _PIDFD_SEND_SIGNAL = 434, 424 # their syscall numbers, the same on every Linux pp runs on
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _syscall(nr: int, *args) -> int:
|
|
255
|
+
"""A syscall by its number, for a Python built without the wrapper for it. A failure is an OSError, as the
|
|
256
|
+
wrapper's would be."""
|
|
257
|
+
import ctypes # imported here: every command imports this module, and only this path needs ctypes
|
|
258
|
+
r = ctypes.CDLL(None, use_errno=True).syscall(nr, *args)
|
|
259
|
+
if r < 0:
|
|
260
|
+
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
|
261
|
+
return r
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def pidfd(pid: int) -> int | None:
|
|
265
|
+
"""An fd that holds the process pid names now. The kernel keeps that fd on that process, so a signal sent
|
|
266
|
+
through it reaches that process or nobody, whatever becomes of the pid. None where the kernel refuses one
|
|
267
|
+
(a container whose seccomp profile has no pidfd_open). A Python built without the calls gets them from
|
|
268
|
+
libc, so this is pp's path on every kernel pp runs on, not only on the builds that wrap them."""
|
|
269
|
+
try:
|
|
270
|
+
return os.pidfd_open(pid) if _WRAPPED else _syscall(_PIDFD_OPEN, pid, 0)
|
|
271
|
+
except OSError:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pidfd_signal(fd: int, sig: int) -> None:
|
|
276
|
+
"""Send sig through a pidfd, which pidfd() describes."""
|
|
277
|
+
if _WRAPPED:
|
|
278
|
+
signal.pidfd_send_signal(fd, sig)
|
|
279
|
+
else:
|
|
280
|
+
_syscall(_PIDFD_SEND_SIGNAL, fd, sig, None, 0)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class Handle:
|
|
285
|
+
"""What pp signals: a pid, or a process group's id, with the start time read together with it.
|
|
286
|
+
|
|
287
|
+
group: the id is a process group's. It is signalled with killpg, and it is gone when no member is left.
|
|
288
|
+
fd: a pidfd for a process pp started itself. The kernel keeps that fd on that process, so a signal sent
|
|
289
|
+
through it reaches that process or nobody, whatever became of the pid.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
id: int
|
|
293
|
+
start: int
|
|
294
|
+
group: bool = False
|
|
295
|
+
fd: int | None = None
|
|
296
|
+
|
|
297
|
+
def __post_init__(self):
|
|
298
|
+
if not isinstance(self.id, int) or self.id <= 0 or not isinstance(self.start, int):
|
|
299
|
+
raise ValueError(f"a handle is a pid and the start time read with it, not ({self.id!r}, {self.start!r})")
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def live(cls, pid: int, *, group: bool = False, fd: int | None = None) -> Handle | None:
|
|
303
|
+
"""A handle for a process that is there now, its start time read with it. None when it is not there."""
|
|
304
|
+
p = read(pid)
|
|
305
|
+
return None if p is None else cls(pid, p.start, group=group, fd=fd)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def recorded(cls, id: int | None, start: int | None, *, group: bool = False) -> Handle | None:
|
|
309
|
+
"""A handle from a record written earlier: a job's process group, a worker's pid file. None when either
|
|
310
|
+
half is missing. Such a record names no process, so signalling it would mean signalling whatever holds
|
|
311
|
+
that pid now."""
|
|
312
|
+
if not id or start is None:
|
|
313
|
+
return None
|
|
314
|
+
return cls(int(id), int(start), group=group)
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def running(cls, id: int | None, start: int | None) -> Handle | None:
|
|
318
|
+
"""A handle from such a record, where the process it names is running now: the same pid, the same
|
|
319
|
+
start time, and not a zombie. None where the record names no process, or one that has gone.
|
|
320
|
+
|
|
321
|
+
What a reader asks when the question is whether a record is a live process's own, rather than what to
|
|
322
|
+
signal: a record that names a process that is gone is a record nobody is writing any more."""
|
|
323
|
+
h = cls.recorded(id, start)
|
|
324
|
+
return h if h is not None and alive(h.id, h.start) else None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def raise_to_self(sig: int) -> None:
|
|
328
|
+
"""Deliver sig to this process now: its handler runs, or its default action ends the process."""
|
|
329
|
+
os.kill(os.getpid(), sig)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _reused(h: Handle) -> bool:
|
|
333
|
+
"""A live process holds the handle's id with another start time: the number has been handed on."""
|
|
334
|
+
p = read(h.id)
|
|
335
|
+
return p is not None and p.start != h.start
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _there(h: Handle) -> bool:
|
|
339
|
+
"""The handle's target is still there: the process itself, or any member of the group."""
|
|
340
|
+
return bool(members(h.id)) if h.group else alive(h.id, h.start)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _send(h: Handle, sig: int) -> str | None:
|
|
344
|
+
"""Send sig to the handle's target: None when it was sent, else the outcome saying why it was not."""
|
|
345
|
+
if h.group:
|
|
346
|
+
# a group's id is the pid of its leader, and Linux never hands out a pid that is still a group id. A
|
|
347
|
+
# process holding that pid with another start time therefore means the group is gone and the number
|
|
348
|
+
# reused, while no process holding it at all leaves members of the group that may still be running.
|
|
349
|
+
if _reused(h):
|
|
350
|
+
return "gone"
|
|
351
|
+
elif not alive(h.id, h.start):
|
|
352
|
+
# with a pidfd the identity is read after that fd is open, and the fd holds one process, so nothing can
|
|
353
|
+
# come between what was read here and where the signal goes
|
|
354
|
+
return "gone"
|
|
355
|
+
try:
|
|
356
|
+
if h.group:
|
|
357
|
+
os.killpg(h.id, sig)
|
|
358
|
+
elif h.fd is not None:
|
|
359
|
+
pidfd_signal(h.fd, sig)
|
|
360
|
+
else:
|
|
361
|
+
os.kill(h.id, sig)
|
|
362
|
+
except ProcessLookupError:
|
|
363
|
+
return "gone"
|
|
364
|
+
except PermissionError:
|
|
365
|
+
return "denied"
|
|
366
|
+
except OSError:
|
|
367
|
+
if h.fd is None:
|
|
368
|
+
raise
|
|
369
|
+
return _send(Handle(h.id, h.start, group=h.group), sig) # no pidfd here: the pinned pid instead
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _wait_gone(h: Handle, grace: float) -> bool:
|
|
374
|
+
"""Whether the target is gone within grace seconds."""
|
|
375
|
+
deadline = time.monotonic() + max(grace, 0)
|
|
376
|
+
while True:
|
|
377
|
+
if not _there(h):
|
|
378
|
+
return True
|
|
379
|
+
if time.monotonic() >= deadline:
|
|
380
|
+
return False
|
|
381
|
+
time.sleep(_LOOK)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def signal_pid(h: Handle, sig: int = signal.SIGTERM) -> str:
|
|
385
|
+
"""Signal one process: it goes out, and this returns. -> one of three outcomes:
|
|
386
|
+
|
|
387
|
+
alive it was signalled and is still there gone it was not there to signal
|
|
388
|
+
denied pp is not allowed to signal it
|
|
389
|
+
|
|
390
|
+
Nothing waits here. A caller that waits for its targets to end, and SIGKILLs the ones that do not, asks
|
|
391
|
+
`signal_and_wait` for one window over all of them; a worker waits for its own jobs a tick at a time
|
|
392
|
+
(`jobq.decide`), so no wait of its is inside a signal."""
|
|
393
|
+
if h.group:
|
|
394
|
+
raise ValueError("a group handle: signal_group signals a process group")
|
|
395
|
+
return _send(h, sig) or "alive"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def signal_group(h: Handle, sig: int = signal.SIGTERM) -> str:
|
|
399
|
+
"""Signal every process in a group (a job, which pp starts in a session of its own), with signal_pid's
|
|
400
|
+
outcomes. A group is gone when no member is left, so a leader that exited while its children run is alive."""
|
|
401
|
+
if not h.group:
|
|
402
|
+
raise ValueError("a process handle: signal_pid signals one process")
|
|
403
|
+
return _send(h, sig) or "alive"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def signal_and_wait(pids: list[int], sig: int = signal.SIGTERM, grace: float = 3.0, escalate: bool = True,
|
|
407
|
+
starts: dict[int, int] | None = None) -> dict[int, str]:
|
|
408
|
+
"""Signal each pid, wait up to grace seconds for them together, then SIGKILL the survivors when escalate.
|
|
409
|
+
-> {pid: outcome}: signal_pid's three, and two more for the waiting this does, `exited` for one that ended
|
|
410
|
+
after the signal and `killed` for one that needed the SIGKILL. starts pins the identities; a pid with no
|
|
411
|
+
start is read now, and one that is gone is never signalled. What `pp kill` sends: the grace is one window
|
|
412
|
+
for every target, so a long list of them is not a long wait."""
|
|
413
|
+
starts = starts or {}
|
|
414
|
+
res: dict[int, str] = {}
|
|
415
|
+
sent: list[Handle] = []
|
|
416
|
+
for pid in pids:
|
|
417
|
+
h = Handle.recorded(pid, starts.get(pid)) or Handle.live(pid)
|
|
418
|
+
if h is None:
|
|
419
|
+
res[pid] = "gone"
|
|
420
|
+
continue
|
|
421
|
+
r = _send(h, sig)
|
|
422
|
+
if r is not None:
|
|
423
|
+
res[pid] = r
|
|
424
|
+
else:
|
|
425
|
+
sent.append(h)
|
|
426
|
+
deadline = time.monotonic() + max(grace, 0)
|
|
427
|
+
pending = list(sent)
|
|
428
|
+
while pending and time.monotonic() < deadline:
|
|
429
|
+
pending = [h for h in pending if _there(h)]
|
|
430
|
+
if pending:
|
|
431
|
+
time.sleep(_LOOK)
|
|
432
|
+
for h in sent:
|
|
433
|
+
if h not in pending:
|
|
434
|
+
res[h.id] = "exited"
|
|
435
|
+
for h in pending:
|
|
436
|
+
if not _there(h):
|
|
437
|
+
res[h.id] = "exited"
|
|
438
|
+
elif not escalate or sig == signal.SIGKILL:
|
|
439
|
+
res[h.id] = "alive"
|
|
440
|
+
else:
|
|
441
|
+
r = _send(h, signal.SIGKILL)
|
|
442
|
+
if r == "gone":
|
|
443
|
+
res[h.id] = "exited"
|
|
444
|
+
elif r is not None:
|
|
445
|
+
res[h.id] = r
|
|
446
|
+
else:
|
|
447
|
+
res[h.id] = "killed" if _wait_gone(h, _KILL_WAIT) else "alive"
|
|
448
|
+
return res
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---- starting a process ---------------------------------------------------------------------------
|
|
452
|
+
#
|
|
453
|
+
# Every process pp starts goes through spawn, or through run, git, git_child and exec_replace, which are spawn
|
|
454
|
+
# with git's decisions or the waiting done. Nothing else in pp calls subprocess or os.exec
|
|
455
|
+
# (tests/test_constructs.py keeps it that way).
|
|
456
|
+
# spawn's first three keywords have no default, so no call site is silent about the group its child runs in, what
|
|
457
|
+
# that child can read, or the environment it is given.
|
|
458
|
+
|
|
459
|
+
PIPE, STDOUT, DEVNULL = subprocess.PIPE, subprocess.STDOUT, subprocess.DEVNULL
|
|
460
|
+
READY_FD = "PP_READY_FD" # ready=True: the fd the child writes one byte to once its own setup is done
|
|
461
|
+
GIT_ENV = {"GIT_TERMINAL_PROMPT": "0"} # git's own prompt is off; ssh's passphrase prompt is not git's
|
|
462
|
+
GIT_WAIT = 300.0 # how long any one git command may take
|
|
463
|
+
DRAIN_WAIT = 2.0 # how long an ended child's pipes are read before they are closed (run)
|
|
464
|
+
_STDIN = {"none": DEVNULL, "pipe": PIPE}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass
|
|
468
|
+
class Result:
|
|
469
|
+
"""What a finished process left behind. returncode is None when it never finished: its time ran out and pp
|
|
470
|
+
ended it (timed_out), or it could not be started at all. stdout and stderr are what pp captured, as text
|
|
471
|
+
unless the caller asked for bytes."""
|
|
472
|
+
|
|
473
|
+
returncode: int | None
|
|
474
|
+
stdout: str
|
|
475
|
+
stderr: str
|
|
476
|
+
timed_out: bool = False
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_ENDING = {signal.SIGINT, signal.SIGTERM}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _default_signals():
|
|
483
|
+
"""In the child, between fork and exec: INT and TERM back to their default action, and not blocked. SIG_IGN
|
|
484
|
+
and the signal mask both survive exec, so a pp whose own caller ignores a signal (pp started from a
|
|
485
|
+
background shell, or a suite run as a background job), or a thread that blocks them (`pp http` serves with
|
|
486
|
+
both blocked), would hand that on, and the child would never see a Ctrl-C or a `pp kill`. A handler does not
|
|
487
|
+
survive exec, so only an ignored or blocked signal has to be undone."""
|
|
488
|
+
for s in _ENDING:
|
|
489
|
+
signal.signal(s, signal.SIG_DFL)
|
|
490
|
+
signal.pthread_sigmask(signal.SIG_UNBLOCK, _ENDING)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _reset_ignored():
|
|
494
|
+
"""_default_signals where this process ignores INT or TERM, or this thread blocks one, else None, so no code
|
|
495
|
+
runs between fork and exec unless there is something to undo."""
|
|
496
|
+
ignored = any(signal.getsignal(s) is signal.SIG_IGN for s in _ENDING)
|
|
497
|
+
return _default_signals if ignored or _ENDING & signal.pthread_sigmask(signal.SIG_BLOCK, ()) else None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class Child:
|
|
501
|
+
"""A process pp started: its pipes, its exit status, and the handles that signal it.
|
|
502
|
+
|
|
503
|
+
`handle` is the process; `group` is its whole process group, where it leads one of its own. end() prefers
|
|
504
|
+
the group, so a shell's children end with the shell.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def __init__(self, p: subprocess.Popen, group: str):
|
|
508
|
+
self.p = p
|
|
509
|
+
self.pid = p.pid
|
|
510
|
+
now = read(p.pid) # read once, while the process is certainly there: pp holds it until it waits
|
|
511
|
+
self.handle = Handle(p.pid, now.start) if now else None
|
|
512
|
+
self.group = Handle(p.pid, now.start, group=True) if now and group == "own" else None
|
|
513
|
+
|
|
514
|
+
stdin = property(lambda self: self.p.stdin)
|
|
515
|
+
stdout = property(lambda self: self.p.stdout)
|
|
516
|
+
stderr = property(lambda self: self.p.stderr)
|
|
517
|
+
returncode = property(lambda self: self.p.returncode)
|
|
518
|
+
|
|
519
|
+
def poll(self) -> int | None:
|
|
520
|
+
return self.p.poll()
|
|
521
|
+
|
|
522
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
523
|
+
return self.p.wait(timeout)
|
|
524
|
+
|
|
525
|
+
def communicate(self, input=None, timeout: float | None = None):
|
|
526
|
+
return self.p.communicate(input, timeout)
|
|
527
|
+
|
|
528
|
+
def close_pipes(self):
|
|
529
|
+
"""Close pp's ends of its pipes, once pp has stopped reading them. communicate() does this itself."""
|
|
530
|
+
for pipe in (self.p.stdin, self.p.stdout, self.p.stderr):
|
|
531
|
+
if pipe is not None and not pipe.closed:
|
|
532
|
+
pipe.close()
|
|
533
|
+
|
|
534
|
+
def end(self, sig: int = signal.SIGTERM) -> str:
|
|
535
|
+
"""Signal it: its whole group where it leads one, so nothing it started runs on. -> signal_pid's
|
|
536
|
+
outcome, which says nothing about whether it ended: `wait(timeout)` is how a caller waits for that.
|
|
537
|
+
|
|
538
|
+
A process pp holds goes through a pidfd, opened for the signal and closed after it: the kernel keeps that
|
|
539
|
+
fd on this process, so the signal cannot reach another that was given its pid."""
|
|
540
|
+
if self.group is not None:
|
|
541
|
+
return signal_group(self.group, sig)
|
|
542
|
+
if self.handle is None:
|
|
543
|
+
return "gone"
|
|
544
|
+
fd = pidfd(self.pid)
|
|
545
|
+
try:
|
|
546
|
+
return signal_pid(self.handle if fd is None else replace(self.handle, fd=fd), sig)
|
|
547
|
+
finally:
|
|
548
|
+
if fd is not None:
|
|
549
|
+
os.close(fd)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def spawn(argv: list, *, group: str, stdin: str, env, cwd: str | None = None, stdout=None, stderr=None,
|
|
553
|
+
add_env: dict | None = None, text: bool = False, ready: bool = False,
|
|
554
|
+
ready_wait: float = 60.0) -> Child:
|
|
555
|
+
"""Start argv. Three things every caller states, which is why they have no default:
|
|
556
|
+
|
|
557
|
+
group "own": a session of its own, so no Ctrl-C in pp's terminal reaches it and its whole group can be
|
|
558
|
+
signalled together (a job, a hook, a worker, anything pp must be able to end itself). "shared": it
|
|
559
|
+
stays in pp's group and terminal, so a Ctrl-C reaches it as well (a gate's check, a batch command).
|
|
560
|
+
stdin "none": /dev/null, so it can never read pp's input nor stop waiting for input. "pipe": pp writes
|
|
561
|
+
to it. An open fd: that file, as the child's input. The child is given the same open file, so a
|
|
562
|
+
lock held on it is the child's for as long as the child lives, and pp closes its own copy
|
|
563
|
+
afterwards (a job's run claim). Nothing pp starts is given pp's own input.
|
|
564
|
+
env "scrubbed": pp's environment without the calling agent session, for anything pp starts on its own.
|
|
565
|
+
"inherit": pp's environment as it is. A dict: exactly that environment. add_env goes on top.
|
|
566
|
+
|
|
567
|
+
ready: return only once the child has written a byte to the fd named by $PP_READY_FD, which it does when its
|
|
568
|
+
own setup is done, or once that pipe is at end of file because the child ended first. Without it spawn
|
|
569
|
+
returns once the child has exec'd, which says nothing about what the child has done since: a handler it
|
|
570
|
+
installs is not installed, a port it binds is not bound, and its command line may still read empty.
|
|
571
|
+
"""
|
|
572
|
+
if group not in ("own", "shared"):
|
|
573
|
+
raise ValueError(f'group {group!r}: "own" or "shared"')
|
|
574
|
+
if not isinstance(stdin, int) and stdin not in _STDIN:
|
|
575
|
+
raise ValueError(f'stdin {stdin!r}: "none", "pipe", or an open fd')
|
|
576
|
+
if env == "scrubbed":
|
|
577
|
+
envv = session.scrubbed_env()
|
|
578
|
+
elif env == "inherit":
|
|
579
|
+
envv = dict(os.environ)
|
|
580
|
+
elif isinstance(env, dict):
|
|
581
|
+
envv = dict(env)
|
|
582
|
+
else:
|
|
583
|
+
raise ValueError(f'env {env!r}: "scrubbed", "inherit", or the environment to give it')
|
|
584
|
+
envv.update(add_env or {})
|
|
585
|
+
r = w = None
|
|
586
|
+
try:
|
|
587
|
+
if ready:
|
|
588
|
+
r, w = os.pipe()
|
|
589
|
+
envv[READY_FD] = str(w)
|
|
590
|
+
p = subprocess.Popen(argv, cwd=cwd, env=envv,
|
|
591
|
+
stdin=stdin if isinstance(stdin, int) else _STDIN[stdin], stdout=stdout, stderr=stderr,
|
|
592
|
+
text=text, start_new_session=group == "own",
|
|
593
|
+
preexec_fn=_reset_ignored(), pass_fds=() if w is None else (w,),
|
|
594
|
+
**({"errors": "replace"} if text else {}))
|
|
595
|
+
except BaseException:
|
|
596
|
+
for fd in (r, w):
|
|
597
|
+
if fd is not None:
|
|
598
|
+
os.close(fd)
|
|
599
|
+
raise
|
|
600
|
+
if w is not None:
|
|
601
|
+
os.close(w) # pp's copy: with only the child's left, the child's end closes the pipe
|
|
602
|
+
c = Child(p, group)
|
|
603
|
+
if r is not None:
|
|
604
|
+
try:
|
|
605
|
+
if not select.select([r], [], [], ready_wait)[0]:
|
|
606
|
+
c.end(signal.SIGKILL) # and waited for, as run() does: nothing pp killed is left a zombie
|
|
607
|
+
c.wait()
|
|
608
|
+
c.close_pipes()
|
|
609
|
+
raise TimeoutError(f"{argv[0]} did not say it was ready within {ready_wait:g}s")
|
|
610
|
+
os.read(r, 1)
|
|
611
|
+
finally:
|
|
612
|
+
os.close(r)
|
|
613
|
+
return c
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _drained(c: Child, text: bool) -> tuple:
|
|
617
|
+
"""End the child and read what is left in its pipes, for at most DRAIN_WAIT, then close them. -> what was
|
|
618
|
+
read by then.
|
|
619
|
+
|
|
620
|
+
The bound is what a child sharing pp's group makes necessary: it is ended by itself, so the children it
|
|
621
|
+
started run on, and they hold the write ends of these pipes. Reading to the end of them would wait for a
|
|
622
|
+
process pp has no handle on (git's ssh, sitting on a passphrase prompt)."""
|
|
623
|
+
c.end(signal.SIGKILL)
|
|
624
|
+
try:
|
|
625
|
+
out, err = c.communicate(timeout=DRAIN_WAIT)
|
|
626
|
+
except subprocess.TimeoutExpired as late:
|
|
627
|
+
out, err = late.output, late.stderr # what was read, as bytes even where the caller asked for text
|
|
628
|
+
if text:
|
|
629
|
+
out, err = (s.decode(errors="replace") if isinstance(s, bytes) else s for s in (out, err))
|
|
630
|
+
c.close_pipes() # the rest stays unread: nothing pp may end holds the other side
|
|
631
|
+
c.wait() # the child itself took the SIGKILL
|
|
632
|
+
return out or "", err or ""
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def run(argv: list, *, group: str, stdin: str, env, cwd: str | None = None, add_env: dict | None = None,
|
|
636
|
+
stdout=PIPE, stderr=PIPE, text: bool = True, timeout: float | None = None) -> Result:
|
|
637
|
+
"""Start argv, wait for it, and return what it left. spawn's keywords, and:
|
|
638
|
+
|
|
639
|
+
timeout: when its time runs out the child is ended, and Result.timed_out says so instead of an exception.
|
|
640
|
+
What is ended is its whole group, where it has one of its own, so a shell's children go with it; a child
|
|
641
|
+
that shares pp's group is ended by itself, since that group holds pp as well. An interruption while pp
|
|
642
|
+
waits, such as a Ctrl-C, ends the child the same way: nothing pp started is left behind.
|
|
643
|
+
|
|
644
|
+
A shared child's own children are not ended with it, and they keep the write ends of its pipes: `pp cmd
|
|
645
|
+
add <ssh-url>` runs git in pp's group so ssh can ask for a passphrase there, and that ssh outlives the
|
|
646
|
+
git pp killed. The pipes are therefore read for at most DRAIN_WAIT seconds after the child is ended and
|
|
647
|
+
then closed (_drained), so what a run that timed out returns is what pp had read by then, and no process
|
|
648
|
+
holding a pipe can hold the run.
|
|
649
|
+
"""
|
|
650
|
+
c = spawn(argv, group=group, stdin=stdin, env=env, cwd=cwd, add_env=add_env,
|
|
651
|
+
stdout=stdout, stderr=stderr, text=text)
|
|
652
|
+
try:
|
|
653
|
+
out, err = c.communicate(timeout=timeout)
|
|
654
|
+
return Result(c.returncode, out or "", err or "")
|
|
655
|
+
except subprocess.TimeoutExpired:
|
|
656
|
+
return Result(None, *_drained(c, text), timed_out=True)
|
|
657
|
+
except BaseException:
|
|
658
|
+
c.end(signal.SIGKILL)
|
|
659
|
+
c.close_pipes() # nothing reads what is left: this run is ending, and the read has no bound
|
|
660
|
+
c.wait()
|
|
661
|
+
raise
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def git(*args: str, group: str, cwd: str | None = None, timeout: float = GIT_WAIT) -> Result:
|
|
665
|
+
"""git, the one way pp runs it: nothing on its stdin, git's own prompt off, and a bound on how long it may
|
|
666
|
+
take. returncode None: it is not installed, or its time ran out (timed_out says which).
|
|
667
|
+
|
|
668
|
+
group has no default, because it decides whether git can reach a terminal. "shared": git stays in pp's
|
|
669
|
+
session, so ssh can ask there for a key passphrase or a host key, which is what a remote over ssh may need.
|
|
670
|
+
"own": a session of its own, which has no terminal, so nothing it runs can ask the person anything; pp ends
|
|
671
|
+
it on a timeout or an interruption rather than leaving it behind.
|
|
672
|
+
"""
|
|
673
|
+
try:
|
|
674
|
+
return run(["git", *args], group=group, stdin="none", env="inherit", add_env=GIT_ENV, cwd=cwd,
|
|
675
|
+
timeout=timeout)
|
|
676
|
+
except FileNotFoundError:
|
|
677
|
+
return Result(None, "", "git is not installed")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def git_child(*args: str, cwd: str | None = None) -> Child:
|
|
681
|
+
"""git whose output pp reads as it comes, an archive piped into tar. It decides what git() decides, bar one:
|
|
682
|
+
nothing on its stdin, git's own prompt off, and a session of its own, so it reaches no terminal and pp can
|
|
683
|
+
end it. How long it may take is not decided here: pp is reading this child, so the reader bounds the time
|
|
684
|
+
and ends it."""
|
|
685
|
+
return spawn(["git", *args], group="own", stdin="none", env="inherit", add_env=GIT_ENV, cwd=cwd,
|
|
686
|
+
stdout=PIPE, stderr=PIPE)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def say_ready() -> None:
|
|
690
|
+
"""Tell the pp that started this process with ready=True that this process's own setup is done: one byte to
|
|
691
|
+
the fd it named in $PP_READY_FD. Nothing happens when nobody is waiting, so a process started by hand runs
|
|
692
|
+
the same way. The variable is dropped afterwards, so nothing this process starts inherits it."""
|
|
693
|
+
fd = os.environ.pop(READY_FD, "")
|
|
694
|
+
if not fd.isdigit():
|
|
695
|
+
return
|
|
696
|
+
try:
|
|
697
|
+
os.write(int(fd), b"1")
|
|
698
|
+
os.close(int(fd))
|
|
699
|
+
except OSError:
|
|
700
|
+
pass # whoever waited for it has gone; this process goes on
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def exec_replace(path: str, argv: list) -> None:
|
|
704
|
+
"""Become another program: pp's pid, group and terminal stay as they are, and nothing after this runs. What
|
|
705
|
+
an external pp-NAME command gets, so it behaves as if the user had run it."""
|
|
706
|
+
os.execv(path, [path] + argv)
|