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/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)