froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Cross-platform process-lifecycle primitives behind a single seam.
|
|
2
|
+
|
|
3
|
+
The orchestrator needs four operations on a pid it launched: politely stop it,
|
|
4
|
+
force-kill it when it ignores that, check whether it is still alive, and — to
|
|
5
|
+
guard against pid reuse before a force-kill — read a stable per-process identity.
|
|
6
|
+
On POSIX these are ``os.kill`` calls; on Windows they are ``taskkill`` / psutil.
|
|
7
|
+
Quarantining them behind :class:`ProcessHost` lets a native-Windows backend slot
|
|
8
|
+
in as a new subclass + one registration line, with no edits to the POSIX bodies
|
|
9
|
+
or to the callers (`runs.stop_run`, the TUI liveness column).
|
|
10
|
+
|
|
11
|
+
On Linux/macOS — and WSL, which *is* Linux — these preserve today's exact
|
|
12
|
+
behavior; the Windows branch degrades gracefully and is not yet exercised (no
|
|
13
|
+
Windows backend ships in this pass). Selection mirrors the multiplexer registry
|
|
14
|
+
in :mod:`froid_loop.adapters.multiplexer`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import signal
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Callable
|
|
28
|
+
|
|
29
|
+
from . import envvars
|
|
30
|
+
|
|
31
|
+
# SIGKILL is absent on Windows; fall back to SIGTERM so attribute access never
|
|
32
|
+
# raises. The POSIX host references this rather than ``signal.SIGKILL`` directly.
|
|
33
|
+
SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM) # portability: SIGKILL absent on Windows
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProcessHostError(Exception):
|
|
37
|
+
"""A process-lifecycle operation could not be carried out on this platform."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProcessHost(ABC):
|
|
41
|
+
"""The four pid operations the orchestrator needs, abstracted over the OS."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def terminate(self, pid: int) -> None:
|
|
45
|
+
"""Politely ask ``pid`` to stop (POSIX SIGTERM / Windows ``taskkill``).
|
|
46
|
+
Raises the ``OSError`` family (``ProcessLookupError``/``PermissionError``)
|
|
47
|
+
so callers keep their "already gone / not ours" handling."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def force_kill(self, pid: int) -> None:
|
|
51
|
+
"""Forcibly kill ``pid`` (POSIX SIGKILL / Windows ``taskkill /F /T``). The
|
|
52
|
+
escalation when ``terminate`` is ignored; only call once identity is
|
|
53
|
+
confirmed, never on a possibly-reused pid."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def is_alive(self, pid: int) -> bool:
|
|
57
|
+
"""Read-only liveness check for ``pid`` (no signal sent)."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def identity(self, pid: int) -> float | None:
|
|
61
|
+
"""A value that stays constant for the life of ``pid`` but changes if the
|
|
62
|
+
pid is reused — a PID-reuse guard for the force-kill escalation. ``None``
|
|
63
|
+
where the platform can't provide one (callers must refuse to force-kill
|
|
64
|
+
rather than risk an unrelated process)."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def hook_interpreter(self) -> str:
|
|
68
|
+
"""The command prefix that runs a froid-loop python hook script on this
|
|
69
|
+
host, interpolated into the hook registrations `install`/`probe` write
|
|
70
|
+
(the script path + canonical event are appended by the caller). POSIX runs
|
|
71
|
+
the ``python3`` on PATH; a Windows host overrides it (no ``python3`` there)
|
|
72
|
+
so hook registration never branches on ``sys.platform`` at the call site."""
|
|
73
|
+
|
|
74
|
+
def alive_and_ours(self, pid: int, identity: float | None) -> bool:
|
|
75
|
+
"""Identity-aware liveness: True only when ``pid`` is alive **and** still the
|
|
76
|
+
same process whose ``identity`` we recorded. A reused pid (immediate on
|
|
77
|
+
Windows) fails the identity match and reads as gone — the reuse guard the
|
|
78
|
+
bare :meth:`is_alive` existence probe lacks.
|
|
79
|
+
|
|
80
|
+
``identity is None`` (a legacy pid file with no persisted identity, or a
|
|
81
|
+
platform that can't provide one) degrades to :meth:`is_alive` — today's
|
|
82
|
+
bare-existence behavior, with the documented residual reuse risk. Kept
|
|
83
|
+
distinct from :meth:`is_alive` so existence and ownership are never
|
|
84
|
+
conflated again.
|
|
85
|
+
|
|
86
|
+
Destructive paths use this strict check; non-destructive TUI reads use
|
|
87
|
+
:meth:`liveness_of` to preserve an ``'unknown'`` state. Derived from
|
|
88
|
+
:meth:`liveness_of` — one decision table, two projections — so the binary
|
|
89
|
+
and tri-state probes can never drift: gone, reused, or unreadable
|
|
90
|
+
(``'unknown'``) all read not-ours here."""
|
|
91
|
+
return self.liveness_of(pid, identity) == "alive"
|
|
92
|
+
|
|
93
|
+
def liveness_of(self, pid: int, identity: float | None) -> str:
|
|
94
|
+
"""Non-destructive tri-state read of *our* engine: ``'alive'`` |
|
|
95
|
+
``'dead'`` | ``'unknown'``. A pid that still exists but whose identity is
|
|
96
|
+
unreadable reads ``'unknown'``, never ``'dead'``."""
|
|
97
|
+
if pid <= 0:
|
|
98
|
+
return "dead"
|
|
99
|
+
if identity is None:
|
|
100
|
+
return "alive" if self.is_alive(pid) else "dead"
|
|
101
|
+
current = self.identity(pid)
|
|
102
|
+
if current == identity:
|
|
103
|
+
return "alive"
|
|
104
|
+
if current is None and self.is_alive(pid):
|
|
105
|
+
return "unknown"
|
|
106
|
+
return "dead"
|
|
107
|
+
|
|
108
|
+
def descendants(self, pid: int) -> dict[int, float | None]:
|
|
109
|
+
"""Transitive descendants of ``pid`` (children, grandchildren, …) as a
|
|
110
|
+
pid → :meth:`identity` snapshot, for the teardown reap that must chase a
|
|
111
|
+
session's whole process tree, not just the pane root — a detached straggler
|
|
112
|
+
(setsid, a double-fork survivor) escapes the pane pgid the window kill
|
|
113
|
+
signals. The identity is captured DURING enumeration — from the same
|
|
114
|
+
``/proc`` read (Linux) or the same enumerated psutil ``Process`` object —
|
|
115
|
+
so a pid reaped-and-reused after enumeration can never be authenticated by
|
|
116
|
+
a later lookup; a member whose identity can't be captured is OMITTED, never
|
|
117
|
+
returned unstamped. ``None`` values are reserved for platforms that
|
|
118
|
+
genuinely can't stamp one — consumers must treat ``None`` as unconfirmable
|
|
119
|
+
(never signal it).
|
|
120
|
+
|
|
121
|
+
NEVER raises: ``{}`` on an unsupported platform, a missing psutil, a gone
|
|
122
|
+
pid, or any read error — this feeds the kill escalation, which must never be
|
|
123
|
+
the thing that raises. The default enumerates via psutil (macOS/Windows);
|
|
124
|
+
the Linux host overrides it with a psutil-free ``/proc`` scan so the core
|
|
125
|
+
stays dep-free there."""
|
|
126
|
+
if pid <= 0:
|
|
127
|
+
return {}
|
|
128
|
+
return _psutil_descendants(pid)
|
|
129
|
+
|
|
130
|
+
def shell_quote(self, arg: str) -> str:
|
|
131
|
+
"""Quote ``arg`` for the shell that runs this host's hook commands, so the
|
|
132
|
+
argument-quoting axis sits behind the same seam as ``hook_interpreter``. Not
|
|
133
|
+
abstract: the default is POSIX ``shlex.quote``; a Windows host overrides it
|
|
134
|
+
(POSIX quoting mangles ``C:\\Program Files\\...`` paths)."""
|
|
135
|
+
return shlex.quote(arg)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PosixProcessHost(ProcessHost):
|
|
139
|
+
"""Linux/macOS/WSL: ``os.kill`` for signalling and the read-only existence
|
|
140
|
+
probe; ``/proc`` start-time (Linux) or psutil create-time for identity."""
|
|
141
|
+
|
|
142
|
+
def terminate(self, pid: int) -> None:
|
|
143
|
+
if pid <= 0:
|
|
144
|
+
# 0/negative target a process group (0 is the caller's own group), never
|
|
145
|
+
# a specific process — refuse so a corrupt pid file can't signal us.
|
|
146
|
+
return
|
|
147
|
+
os.kill(pid, signal.SIGTERM)
|
|
148
|
+
|
|
149
|
+
def force_kill(self, pid: int) -> None:
|
|
150
|
+
if pid <= 0:
|
|
151
|
+
return
|
|
152
|
+
os.kill(pid, SIGKILL)
|
|
153
|
+
|
|
154
|
+
def is_alive(self, pid: int) -> bool:
|
|
155
|
+
if pid <= 0:
|
|
156
|
+
# 0/negative target a process group, not a specific process — a corrupt
|
|
157
|
+
# pid file must read as "not alive", never as the caller's own group.
|
|
158
|
+
return False
|
|
159
|
+
try:
|
|
160
|
+
os.kill(pid, 0) # portability: read-only existence probe (POSIX); win32 uses psutil
|
|
161
|
+
except ProcessLookupError:
|
|
162
|
+
return False
|
|
163
|
+
except PermissionError:
|
|
164
|
+
return True # exists, just not ours to signal
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def identity(self, pid: int) -> float | None:
|
|
168
|
+
if pid <= 0:
|
|
169
|
+
return None
|
|
170
|
+
if sys.platform.startswith("linux"):
|
|
171
|
+
return _proc_starttime(pid)
|
|
172
|
+
# macOS (and any non-Linux POSIX): no /proc — fall back to psutil if the
|
|
173
|
+
# optional extra is present, else give up (None → callers won't force-kill).
|
|
174
|
+
try:
|
|
175
|
+
return _psutil().Process(pid).create_time()
|
|
176
|
+
except Exception:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def descendants(self, pid: int) -> dict[int, float | None]:
|
|
180
|
+
if pid <= 0:
|
|
181
|
+
return {}
|
|
182
|
+
if sys.platform.startswith("linux"):
|
|
183
|
+
return _linux_descendants(pid) # psutil-free: keeps the Linux core dep-free
|
|
184
|
+
return super().descendants(pid) # macOS: psutil, guarded by the seam's never-raise
|
|
185
|
+
|
|
186
|
+
def hook_interpreter(self) -> str:
|
|
187
|
+
return "python3"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class WindowsProcessHost(ProcessHost):
|
|
191
|
+
"""Native Windows: ``taskkill`` for signalling, psutil for the non-destructive
|
|
192
|
+
liveness probe and create-time identity. Not exercised in this pass — kept so a
|
|
193
|
+
psmux-class backend can register it without editing the POSIX bodies above."""
|
|
194
|
+
|
|
195
|
+
def terminate(self, pid: int) -> None:
|
|
196
|
+
if pid <= 0:
|
|
197
|
+
return
|
|
198
|
+
# portability: no os.kill(SIGTERM) on Windows — taskkill is the analogue.
|
|
199
|
+
subprocess.run([_taskkill(), "/PID", str(pid)], check=False, capture_output=True)
|
|
200
|
+
|
|
201
|
+
def force_kill(self, pid: int) -> None:
|
|
202
|
+
if pid <= 0:
|
|
203
|
+
return
|
|
204
|
+
# portability: SIGKILL has no Windows analogue — taskkill /F /T force-kills
|
|
205
|
+
# the process and its child tree.
|
|
206
|
+
subprocess.run(
|
|
207
|
+
[_taskkill(), "/F", "/T", "/PID", str(pid)], check=False, capture_output=True
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def is_alive(self, pid: int) -> bool:
|
|
211
|
+
if pid <= 0:
|
|
212
|
+
return False
|
|
213
|
+
return _psutil().pid_exists(pid)
|
|
214
|
+
|
|
215
|
+
def identity(self, pid: int) -> float | None:
|
|
216
|
+
if pid <= 0:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return _psutil().Process(pid).create_time()
|
|
220
|
+
except Exception:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def hook_interpreter(self) -> str:
|
|
224
|
+
# Windows ships no `python3` launcher; `uv run --no-project python` resolves
|
|
225
|
+
# an interpreter without activating a project venv (hooks fire detached).
|
|
226
|
+
return "uv run --no-project python"
|
|
227
|
+
|
|
228
|
+
def shell_quote(self, arg: str) -> str:
|
|
229
|
+
# POSIX single-quoting breaks Windows paths; list2cmdline is the stdlib's
|
|
230
|
+
# Windows argument quoter (the inverse of how CreateProcess parses argv).
|
|
231
|
+
return subprocess.list2cmdline([arg])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _proc_starttime(pid: int) -> float | None:
|
|
235
|
+
"""The process's start time (clock ticks since boot) from ``/proc/<pid>/stat``
|
|
236
|
+
field 22 — stable for the life of the pid, so it doubles as a reuse guard. The
|
|
237
|
+
comm field (2) is wrapped in parens and may itself contain spaces/parens, so we
|
|
238
|
+
split on the last ``)`` before tokenizing the rest. ``None`` if the process is
|
|
239
|
+
gone or unreadable."""
|
|
240
|
+
try:
|
|
241
|
+
proc = Path("/proc") # portability: Linux-only, guarded by identity()'s platform check
|
|
242
|
+
stat = proc.joinpath(str(pid), "stat").read_text(encoding="utf-8")
|
|
243
|
+
except OSError:
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
after_comm = stat[stat.rindex(")") + 1 :].split()
|
|
247
|
+
return float(after_comm[19]) # field 22 = index 19 after the comm token
|
|
248
|
+
except (ValueError, IndexError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _linux_descendants(pid: int) -> dict[int, float | None]:
|
|
253
|
+
"""Transitive descendants of ``pid``, with their pid-reuse identities, from a
|
|
254
|
+
single pass over ``/proc/*/stat`` (Linux only, psutil-free). Each entry's ppid
|
|
255
|
+
(field 4, index 1 after the comm token) AND starttime identity (field 22,
|
|
256
|
+
index 19 — the very value :func:`_proc_starttime` reads) come from ONE read of
|
|
257
|
+
the same ``stat`` line, split on the last ``)`` exactly as
|
|
258
|
+
:func:`_proc_starttime` does — so ancestry and identity are captured atomically
|
|
259
|
+
per process and a pid reused after the scan can never be authenticated by a
|
|
260
|
+
later lookup. BFS out from ``pid``. Best-effort: a process that exits mid-scan
|
|
261
|
+
simply drops out of the map, an unparsable line is omitted (never
|
|
262
|
+
authenticated), and any error yields ``{}`` so the kill seam never raises. A
|
|
263
|
+
``seen`` set guards against a pid-reuse race fabricating a cycle."""
|
|
264
|
+
try:
|
|
265
|
+
children: dict[int, list[int]] = {}
|
|
266
|
+
identities: dict[int, float] = {}
|
|
267
|
+
for entry in Path("/proc").iterdir(): # portability: Linux-only, guarded by caller
|
|
268
|
+
if not entry.name.isdigit():
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
stat = (entry / "stat").read_text(encoding="utf-8")
|
|
272
|
+
after_comm = stat[stat.rindex(")") + 1 :].split()
|
|
273
|
+
ppid = int(after_comm[1]) # field 4 = ppid
|
|
274
|
+
starttime = float(after_comm[19]) # field 22 = starttime, the identity
|
|
275
|
+
except (OSError, ValueError, IndexError):
|
|
276
|
+
continue # vanished mid-scan / unparsable line — just skip it
|
|
277
|
+
child = int(entry.name)
|
|
278
|
+
children.setdefault(ppid, []).append(child)
|
|
279
|
+
identities[child] = starttime
|
|
280
|
+
except OSError:
|
|
281
|
+
return {}
|
|
282
|
+
out: dict[int, float | None] = {}
|
|
283
|
+
seen: set[int] = set()
|
|
284
|
+
stack = list(children.get(pid, ()))
|
|
285
|
+
while stack:
|
|
286
|
+
child = stack.pop()
|
|
287
|
+
if child in seen:
|
|
288
|
+
continue
|
|
289
|
+
seen.add(child)
|
|
290
|
+
out[child] = identities[child]
|
|
291
|
+
stack.extend(children.get(child, ()))
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _psutil_descendants(pid: int) -> dict[int, float | None]:
|
|
296
|
+
"""Transitive descendants via psutil (non-Linux POSIX + Windows), each stamped
|
|
297
|
+
with the ``create_time()`` of the SAME enumerated ``Process`` object and then
|
|
298
|
+
REVALIDATED against that object's construction-bound identity before it is
|
|
299
|
+
recorded.
|
|
300
|
+
|
|
301
|
+
The revalidation is load-bearing, not belt-and-braces — ``create_time()`` alone
|
|
302
|
+
is NOT reuse-safe here. Per ``psutil.Process._get_ident`` (7.2.2), only the
|
|
303
|
+
``WINDOWS`` branch pre-populates the epoch cache (``self._create_time =
|
|
304
|
+
create_time(fast_only=True)``); the ``LINUX or NETBSD or OSX`` branch binds a
|
|
305
|
+
*monotonic* starttime and leaves ``self._create_time`` unset. So on macOS our
|
|
306
|
+
``create_time()`` is the FIRST call — a raw call-time kernel read, and
|
|
307
|
+
``create_time()`` does not consult ``_raise_if_pid_reused()``. A pid reaped and
|
|
308
|
+
reused between ``children()`` and the stamp would therefore hand back the
|
|
309
|
+
NEWCOMER's identity, which teardown would then authenticate successfully and
|
|
310
|
+
signal (#184 review). ``is_running()`` closes that window: it compares the
|
|
311
|
+
ident captured at construction against a freshly built ``Process``, so a
|
|
312
|
+
generation change reads as not-running and the member is omitted.
|
|
313
|
+
|
|
314
|
+
Order matters — read the identity, THEN validate, THEN record; validating first
|
|
315
|
+
would reopen the same TOCTOU gap. A member whose identity can't be confirmed is
|
|
316
|
+
OMITTED, never returned unstamped. Blanket-except → ``{}`` so a missing psutil
|
|
317
|
+
or an already-gone pid degrades silently — the never-raise contract the kill
|
|
318
|
+
escalation depends on."""
|
|
319
|
+
out: dict[int, float | None] = {}
|
|
320
|
+
try:
|
|
321
|
+
for child in _psutil().Process(pid).children(recursive=True):
|
|
322
|
+
try:
|
|
323
|
+
identity = child.create_time()
|
|
324
|
+
if not child.is_running(): # generation changed under us — don't stamp it
|
|
325
|
+
continue
|
|
326
|
+
out[child.pid] = identity
|
|
327
|
+
except Exception: # nosec B112 - gone/reused mid-walk: omit it
|
|
328
|
+
continue
|
|
329
|
+
except Exception: # the kill-path seam must never raise
|
|
330
|
+
return {}
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _psutil():
|
|
335
|
+
"""Lazily import psutil — a core dep on Windows, the ``non-linux`` extra on
|
|
336
|
+
macOS — used only for the non-destructive Windows/macOS liveness + identity
|
|
337
|
+
probes. The dep-free core never imports it on Linux; raise a clear, actionable
|
|
338
|
+
error if it's missing where it's needed."""
|
|
339
|
+
try:
|
|
340
|
+
import psutil # intentional lazy import — keeps the core dep-free
|
|
341
|
+
except ImportError as exc: # pragma: no cover - exercised only off Linux
|
|
342
|
+
raise ProcessHostError(
|
|
343
|
+
f"process_host: pid operations on {sys.platform!r} need psutil; "
|
|
344
|
+
"on Windows reinstall froid-loop (psutil is a required dependency there), "
|
|
345
|
+
"on macOS run `pip install 'froid-loop[non-linux]'`"
|
|
346
|
+
) from exc
|
|
347
|
+
return psutil
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _taskkill() -> str:
|
|
351
|
+
"""Absolute path to the Windows ``taskkill`` binary. Resolving it from
|
|
352
|
+
``%SystemRoot%\\System32`` rather than invoking ``taskkill`` by name keeps the
|
|
353
|
+
Windows process-search order from picking up a same-named executable planted on
|
|
354
|
+
PATH or in the working directory."""
|
|
355
|
+
return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "taskkill.exe")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# (name, matches(platform) -> bool, factory() -> ProcessHost)
|
|
359
|
+
_HOSTS: list[tuple[str, Callable[[str], bool], Callable[[], ProcessHost]]] = []
|
|
360
|
+
_BUILTINS_LOADED = False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def register_process_host(
|
|
364
|
+
name: str,
|
|
365
|
+
matches: Callable[[str], bool],
|
|
366
|
+
factory: Callable[[], ProcessHost],
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Register a process host. ``matches(sys.platform)`` decides automatic
|
|
369
|
+
selection; ``name`` is the key for the ``FROID_LOOP_PROCESS_HOST`` override.
|
|
370
|
+
Bundled hosts register from :func:`_load_builtin_hosts`; an out-of-tree host
|
|
371
|
+
calls this at import time — no core edit required."""
|
|
372
|
+
_HOSTS.append((name, matches, factory))
|
|
373
|
+
get_process_host.cache_clear() # a later registration must not be shadowed by a cached pick
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _load_builtin_hosts() -> None:
|
|
377
|
+
"""Register the bundled hosts. Idempotent and lazy (called from
|
|
378
|
+
:func:`get_process_host`) to match the multiplexer registry's shape; both
|
|
379
|
+
builtins live in this module, so there is nothing to import."""
|
|
380
|
+
global _BUILTINS_LOADED
|
|
381
|
+
if _BUILTINS_LOADED:
|
|
382
|
+
return
|
|
383
|
+
_BUILTINS_LOADED = True
|
|
384
|
+
register_process_host("posix", lambda platform: platform != "win32", PosixProcessHost)
|
|
385
|
+
register_process_host("windows", lambda platform: platform == "win32", WindowsProcessHost)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@functools.lru_cache(maxsize=1)
|
|
389
|
+
def get_process_host() -> ProcessHost:
|
|
390
|
+
"""Return the process-wide process host, selected by registry.
|
|
391
|
+
|
|
392
|
+
``FROID_LOOP_PROCESS_HOST`` forces a host by name (test / override hook);
|
|
393
|
+
otherwise the first host whose ``matches(sys.platform)`` is true wins. POSIX is
|
|
394
|
+
the default fallback, so behavior on Linux/macOS is unchanged. Cached — tests
|
|
395
|
+
that flip the env var must call ``get_process_host.cache_clear()``."""
|
|
396
|
+
forced = envvars.process_host()
|
|
397
|
+
_load_builtin_hosts()
|
|
398
|
+
for name, matches, factory in _HOSTS:
|
|
399
|
+
if name == forced or (not forced and matches(sys.platform)):
|
|
400
|
+
return factory()
|
|
401
|
+
if forced:
|
|
402
|
+
# An explicit override that matches nothing is a misconfiguration; never
|
|
403
|
+
# silently fall back to POSIX (on win32 os.kill(pid, 0) is destructive).
|
|
404
|
+
known = ", ".join(name for name, _, _ in _HOSTS) or "(none registered)"
|
|
405
|
+
raise ProcessHostError(
|
|
406
|
+
f"FROID_LOOP_PROCESS_HOST={forced!r} matches no registered host; known: {known}"
|
|
407
|
+
)
|
|
408
|
+
return PosixProcessHost() # default fallback
|