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/commands/kill.py ADDED
@@ -0,0 +1,293 @@
1
+ """Kill processes by pid, port or pattern, never the shell that asked
2
+
3
+ pp kill 12345 that pid (SIGTERM, then SIGKILL after --grace seconds if it is still there)
4
+ pp kill -s HUP 12345 another signal; only TERM, INT and QUIT are followed up with SIGKILL
5
+ pp kill :8080 whatever listens on port 8080 (tcp/udp, IPv4/IPv6)
6
+ pp kill 'vite --port' every process whose command line matches the regex; shows them and asks first
7
+ pp kill -x node processes NAMED exactly node (not ones merely mentioning it; names past the
8
+ kernel's 15-character cut match by their program's file name)
9
+ pp kill -n :8080 only show what would be killed
10
+ pp kill --tree 12345 the pid and all its descendants
11
+ pp kill --ports list every listening port with its owner (or docker container)
12
+
13
+ Patterns never match pp itself or any of its ancestors. That is the `pkill -f PATTERN` trap: the pattern is in the
14
+ command line of the shell running pkill, so pkill kills its own shell (exit 143 from `sh -c "pkill -f foo"`,
15
+ which is the SIGTERM it sent itself).
16
+ Patterns only match your own processes unless --any-user.
17
+
18
+ Asking: pid and port targets are explicit and act at once; a pattern shows its matches and asks. Without a
19
+ terminal it refuses unless -y (agents: `-n` to look, then `-y`). A port published by docker is held by root's
20
+ docker-proxy: pp names the container and offers `docker stop` instead of killing the proxy.
21
+
22
+ --tree: pp takes each target's descendants again when it signals, and once more after the grace window, so a
23
+ child forked while it was looking or asking is signalled too. A child started after that last look is out of
24
+ scope. One still alive at the end is listed with the others and the exit status is 1.
25
+
26
+ Exit: 0 every target is gone, or signalled where the signal is not followed up with SIGKILL (`-s` naming
27
+ anything but TERM, INT, QUIT or KILL, or `--no-escalate`): a target meant to survive the signal is a success.
28
+ 1 something survived a signal meant to end it, was denied, or nothing matched. 2 refused or bad usage.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import pwd
34
+ import re
35
+ import shutil
36
+ import signal
37
+
38
+ from pp.lib import proc, ui
39
+
40
+
41
+ def _user(uid: int) -> str:
42
+ try:
43
+ return pwd.getpwuid(uid).pw_name
44
+ except KeyError:
45
+ return str(uid)
46
+
47
+
48
+ def _sig(s: str) -> int:
49
+ s = s.upper()
50
+ if s.isdigit():
51
+ return int(s)
52
+ try:
53
+ return int(getattr(signal, s if s.startswith("SIG") else "SIG" + s))
54
+ except AttributeError:
55
+ ui.die(f"unknown signal {s}", 2)
56
+
57
+
58
+ def docker_ports() -> dict[int, list[str]]:
59
+ """host port -> container names publishing it ({} without docker or when it does not answer quickly)."""
60
+ if not shutil.which("docker"):
61
+ return {}
62
+ try:
63
+ r = proc.run(["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"], group="own", stdin="none",
64
+ env="inherit", timeout=5)
65
+ except OSError:
66
+ return {}
67
+ if r.returncode != 0: # it did not answer in time, or it complained: no container to name
68
+ return {}
69
+ res: dict[int, list[str]] = {}
70
+ for line in r.stdout.splitlines():
71
+ name, _, ports = line.partition("\t")
72
+ # "0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 0.0.0.0:9000-9002->9000-9002/tcp"
73
+ for m in re.finditer(r":(\d+)(?:-(\d+))?->", ports):
74
+ lo, hi = int(m.group(1)), int(m.group(2) or m.group(1))
75
+ for p in range(lo, hi + 1):
76
+ if name not in res.setdefault(p, []):
77
+ res[p].append(name)
78
+ return res
79
+
80
+
81
+ # a signal sent to end a process: one that survives it gets SIGKILL after --grace. Any other (-s HUP to reload,
82
+ # USR1, STOP, 0) is sent alone, because the process is meant to survive it.
83
+ ENDING = {signal.SIGTERM, signal.SIGINT, signal.SIGQUIT, signal.SIGKILL}
84
+
85
+
86
+ def _names(p: proc.Proc) -> set[str]:
87
+ """What -x compares: comm, and argv[0]'s file name; comm is cut at 15 characters (systemd-journald is
88
+ "systemd-journal"), so a longer name only matches the program's own."""
89
+ return {p.comm, os.path.basename(p.cmdline[0])} if p.cmdline else {p.comm}
90
+
91
+
92
+ def _shown(cmd: str, width: int = 120) -> str:
93
+ """A command line as the table shows it: control characters escaped (another program chooses its argv, and an
94
+ escape sequence or newline in it must not redraw what you are asked to confirm), cut with … past width."""
95
+ cmd = ui.printable(cmd, tabs=False)
96
+ return cmd if len(cmd) <= width else cmd[:width - 1] + "…"
97
+
98
+
99
+ def _row(p: proc.Proc, why: str) -> list:
100
+ return [p.pid, _user(p.uid), ui.human_duration(p.age()), ui.human_size(p.rss_kb * 1024), why, _shown(p.cmd)]
101
+
102
+
103
+ def _tree_now(trees: list[int], known: dict[int, int], me: set[int]) -> dict[int, proc.Proc]:
104
+ """The descendants of each --tree target that are not in `known` yet, read now. The plan comes from one
105
+ snapshot, so a tree is taken again before pp signals and again after the grace window: a child forked in
106
+ between is in scope, and nothing is reported gone that pp never looked for."""
107
+ if not trees:
108
+ return {}
109
+ procs = proc.processes()
110
+ return {d: procs[d] for pid in trees for d in proc.descendants(pid, procs)
111
+ if d not in known and d not in me}
112
+
113
+
114
+ def cmd_ports(a) -> int:
115
+ socks = proc.sockets()
116
+ owners = proc.socket_owners({s["inode"] for s in socks})
117
+ dports = docker_ports()
118
+ procs = {}
119
+ rows, js = [], []
120
+ for s in sorted(socks, key=lambda s: (s["port"], s["proto"])):
121
+ pids = sorted(owners.get(s["inode"], set()))
122
+ who = []
123
+ for pid in pids:
124
+ p = procs.setdefault(pid, proc.read(pid))
125
+ if p:
126
+ who.append(f"{pid} {_shown(p.cmd, 70)}")
127
+ if not who and s["port"] in dports:
128
+ who = ["docker: " + ", ".join(dports[s["port"]])]
129
+ if not who:
130
+ who = ["? (another user's)"]
131
+ addr = f"[{s['addr']}]" if ":" in s["addr"] else s["addr"]
132
+ rows.append([s["proto"], f"{addr}:{s['port']}", "; ".join(who)])
133
+ js.append({**s, "pids": pids, "docker": dports.get(s["port"], [])})
134
+ if a.json:
135
+ ui.print_json(js)
136
+ else:
137
+ print(ui.table(rows, ["proto", "address", "owner"]))
138
+ return 0
139
+
140
+
141
+ def main(argv: list[str]) -> int:
142
+ ap = ui.parser("kill", __doc__)
143
+ ap.add_argument("targets", nargs="*", metavar="TARGET", help="PID, :PORT, or a regex over the command line")
144
+ ap.add_argument("-n", "--dry-run", action="store_true", help="show what would be signalled, do nothing")
145
+ ap.add_argument("-y", "--yes", action="store_true", help="do not ask (patterns, docker stop)")
146
+ ap.add_argument("-s", "--signal", default="TERM", help="signal to send first (default TERM)")
147
+ ap.add_argument("-9", dest="nine", action="store_true", help="SIGKILL straight away")
148
+ ap.add_argument("--grace", type=float, default=3.0,
149
+ help="seconds before escalating to SIGKILL (default 3; after TERM, INT or QUIT only)")
150
+ ap.add_argument("--no-escalate", action="store_true", help="send the signal only; never SIGKILL")
151
+ ap.add_argument("--tree", action="store_true", help="also every descendant of each target")
152
+ ap.add_argument("-x", "--exact", action="store_true", help="pattern is the exact process name (comm, or its program's file name)")
153
+ ap.add_argument("-F", "--fixed", action="store_true", help="pattern is a plain string, not a regex")
154
+ ap.add_argument("-i", "--ignore-case", action="store_true")
155
+ ap.add_argument("--any-user", action="store_true", help="patterns may match other users' processes")
156
+ ap.add_argument("--ports", action="store_true", help="list listening ports and their owners")
157
+ ap.add_argument("--json", action="store_true")
158
+ a = ap.parse_args(argv)
159
+
160
+ if a.ports:
161
+ return cmd_ports(a)
162
+ if not a.targets:
163
+ ap.print_usage()
164
+ return 2
165
+ sig = signal.SIGKILL if a.nine else _sig(a.signal) # a typo fails before anything is shown or asked
166
+
167
+ procs = proc.processes()
168
+ me = set(proc.ancestors(procs=procs))
169
+ uid = os.getuid()
170
+ chosen: dict[int, str] = {} # pid -> why it was chosen
171
+ ask = False
172
+ containers: list[tuple[int, list[str]]] = []
173
+ problems: list[str] = []
174
+
175
+ for t in a.targets:
176
+ if t.isdigit():
177
+ pid = int(t)
178
+ if pid not in procs:
179
+ problems.append(f"no process {pid}")
180
+ elif pid in me:
181
+ problems.append(f"{pid} is pp itself, its caller or an ancestor of both; not killing it")
182
+ else:
183
+ chosen.setdefault(pid, f"pid {pid}")
184
+ elif re.fullmatch(r":\d+", t):
185
+ port = int(t[1:])
186
+ if not 0 < port < 65536:
187
+ ui.die(f"{t}: a port is 1-65535", 2)
188
+ socks, pids = proc.port_owners(port)
189
+ if not socks:
190
+ problems.append(f"nothing listens on port {port}")
191
+ continue
192
+ for pid in sorted(pids):
193
+ if pid in me:
194
+ problems.append(f"port {port} is held by pp's own caller ({pid}); not killing it")
195
+ else:
196
+ chosen.setdefault(pid, f"port {port}")
197
+ if not pids:
198
+ names = docker_ports().get(port)
199
+ if names:
200
+ containers.append((port, names))
201
+ else:
202
+ problems.append(f"port {port} is held by another user's process; try: sudo pp kill :{port}")
203
+ else:
204
+ ask = True
205
+ flags = re.I if a.ignore_case else 0
206
+ try:
207
+ rx = re.compile(re.escape(t) if a.fixed else t, flags)
208
+ except re.error:
209
+ rx = re.compile(re.escape(t), flags)
210
+ hits = 0
211
+ for p in procs.values():
212
+ if p.pid in me or (p.uid != uid and not a.any_user) or not p.cmdline:
213
+ continue # never the caller; kernel threads have no cmdline
214
+ if (any(rx.fullmatch(n) for n in _names(p)) if a.exact else rx.search(p.cmd)):
215
+ chosen.setdefault(p.pid, f"/{t}/")
216
+ hits += 1
217
+ if not hits:
218
+ problems.append(f"no process matches {t!r}" + ("" if a.any_user else " (own processes only; --any-user widens)"))
219
+
220
+ for pid in list(chosen):
221
+ # a `:PORT` owner is read fresh and can be younger than the snapshot: put it in procs, or drop it, so
222
+ # every later use (the table, the uid warning, the JSON, the start times) reads a Proc that exists
223
+ p = procs.get(pid) or proc.read(pid)
224
+ if p:
225
+ procs[pid] = p
226
+ else:
227
+ del chosen[pid]
228
+ problems.append(f"no process {pid} any more")
229
+
230
+ trees = list(chosen) if a.tree else [] # the targets --tree expands, before their descendants join
231
+ for pid in trees:
232
+ for d in proc.descendants(pid, procs):
233
+ if d not in me:
234
+ chosen.setdefault(d, f"child of {pid}")
235
+
236
+ for msg in problems:
237
+ ui.warn(msg)
238
+ others = sorted({_user(procs[pid].uid) for pid in chosen if procs[pid].uid != uid})
239
+ if others and uid != 0:
240
+ ui.warn(f"{sum(procs[pid].uid != uid for pid in chosen)} of these belong to {', '.join(others)}:"
241
+ f" signalling them is denied unless pp runs as root")
242
+ if a.json and (a.dry_run or not chosen and not containers):
243
+ ui.print_json({"pids": {str(k): v for k, v in chosen.items()}, "containers": dict(containers),
244
+ "commands": {str(k): procs[k].cmd for k in chosen}, "problems": problems})
245
+ if not chosen and not containers:
246
+ return 1
247
+
248
+ if chosen and not a.json:
249
+ print(ui.table([_row(procs[pid], why) for pid, why in chosen.items()],
250
+ ["pid", "user", "age", "rss", "matched", "command"]))
251
+ for port, names in containers:
252
+ print(f"port {port} is published by docker container {', '.join(names)}"
253
+ f"; `docker stop {' '.join(names)}` stops it")
254
+
255
+ if a.dry_run:
256
+ return 0
257
+ if (ask or containers) and not ui.confirm(f"signal {len(chosen)} process(es)" + (" and stop the container(s)" if containers else "") + "?", a.yes):
258
+ return 2
259
+
260
+ starts = {pid: procs[pid].start for pid in chosen}
261
+ for pid, p in _tree_now(trees, starts, me).items():
262
+ procs[pid], starts[pid] = p, p.start # a child forked while pp looked, or while it asked
263
+ escalate = sig in ENDING and not a.no_escalate
264
+ grace = a.grace if sig in ENDING else 0 # nothing to wait for after a signal it is meant to survive
265
+ res = proc.signal_and_wait(list(starts), sig, grace, escalate=escalate, starts=starts) if starts else {}
266
+ late = _tree_now(trees, starts, me) # and one forked while the targets were dying
267
+ if late:
268
+ for pid, p in late.items():
269
+ procs[pid], starts[pid] = p, p.start
270
+ res.update(proc.signal_and_wait(list(late), sig, grace, escalate=escalate, starts=starts))
271
+ if not escalate and sig != signal.SIGKILL: # e.g. -s HUP to make a daemon reload: surviving is the point
272
+ res = {k: ("signalled" if v == "alive" else v) for k, v in res.items()}
273
+ stopped = {}
274
+ for port, names in containers:
275
+ # docker stop gives each container 10s of its own to stop, so pp's own bound has to be the looser one
276
+ wait = 30.0 + 15 * len(names)
277
+ try:
278
+ r = proc.run(["docker", "stop", *names], group="own", stdin="none", env="inherit", timeout=wait)
279
+ except OSError as e:
280
+ stopped[",".join(names)] = f"failed: {e}"
281
+ continue
282
+ why = r.stderr.strip() or (f"it did not answer in {wait:g}s" if r.timed_out else "")
283
+ stopped[",".join(names)] = "stopped" if r.returncode == 0 else "failed: " + why
284
+
285
+ if a.json:
286
+ ui.print_json({"pids": {str(k): v for k, v in res.items()}, "containers": stopped,
287
+ "commands": {str(k): procs[k].cmd for k in res if k in procs}, "problems": problems})
288
+ else:
289
+ colour = {"exited": "green", "killed": "yellow", "gone": "dim", "stopped": "green", "signalled": "green"}
290
+ for k, v in [*res.items(), *((f"docker:{n}", v) for n, v in stopped.items())]:
291
+ print(f" {k}: {ui.c(v, colour.get(v, 'red'))}")
292
+ ok = {"exited", "killed", "gone", "stopped", "signalled"}
293
+ return 0 if all(v in ok for v in [*res.values(), *stopped.values()]) and not problems else 1