ringo-task-queue 0.1.0.dev8__py3-none-macosx_13_0_x86_64.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.
- ringo_task_queue/__init__.py +75 -0
- ringo_task_queue/_pb.py +154 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +153 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +797 -0
- ringo_task_queue/_supervisor_child.py +91 -0
- ringo_task_queue/_supervisor_ipc.py +81 -0
- ringo_task_queue/_supervisor_policy.py +182 -0
- ringo_task_queue/_supervisor_process.py +562 -0
- ringo_task_queue/_supervisor_runtime.py +420 -0
- ringo_task_queue/_supervisor_watchdog.py +429 -0
- ringo_task_queue/_version.py +17 -0
- ringo_task_queue/bin/manifest.json +20 -0
- ringo_task_queue/bin/ringo-task-queue-darwin-amd64 +0 -0
- ringo_task_queue/binary.py +253 -0
- ringo_task_queue/client.py +1261 -0
- ringo_task_queue/concurrency.py +480 -0
- ringo_task_queue/daemon.py +507 -0
- ringo_task_queue/errors.py +159 -0
- ringo_task_queue/models.py +449 -0
- ringo_task_queue/py.typed +0 -0
- ringo_task_queue/shared.py +359 -0
- ringo_task_queue/supervisor.py +575 -0
- ringo_task_queue/worker.py +1868 -0
- ringo_task_queue-0.1.0.dev8.dist-info/METADATA +638 -0
- ringo_task_queue-0.1.0.dev8.dist-info/RECORD +27 -0
- ringo_task_queue-0.1.0.dev8.dist-info/WHEEL +4 -0
- ringo_task_queue-0.1.0.dev8.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Private, synchronous resource ownership for the supervisor's gated child.
|
|
2
|
+
|
|
3
|
+
The caller MUST keep the child bootstrap blocked until construction succeeds.
|
|
4
|
+
Windows launchers must also use CREATE_SUSPENDED and call resume_root() after
|
|
5
|
+
assignment, so venv redirectors cannot start an uncontained base interpreter.
|
|
6
|
+
Windows owns an unnamed, non-inheritable Job; all ordinary descendants inherit
|
|
7
|
+
it, even after the root exits. Explicit CREATE_BREAKAWAY_FROM_JOB is permitted
|
|
8
|
+
for the shared daemon, but silent breakaway is deliberately NOT enabled.
|
|
9
|
+
|
|
10
|
+
POSIX callers must use Popen(start_new_session=True). A process group/session
|
|
11
|
+
and psutil creation-time identities are tracked, not process names or a cached
|
|
12
|
+
PID alone. This is not a cgroup: an unobserved child can escape via setsid() or
|
|
13
|
+
setpgid(). Observed escapees remain tracked. If all identity anchors disappear
|
|
14
|
+
before descendants are observed, ownership cannot safely be distinguished from
|
|
15
|
+
PGID reuse; operations fail closed instead of signalling an unrelated group.
|
|
16
|
+
|
|
17
|
+
No child is launched here, and kill() does not perform a graceful STOP/wait.
|
|
18
|
+
Call wait_empty() before close(); a closed object cannot verify termination.
|
|
19
|
+
Only the POSIX backend imports the optional psutil dependency, lazily.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ctypes
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
import time
|
|
29
|
+
from ctypes import wintypes
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .errors import RingoError
|
|
33
|
+
|
|
34
|
+
_JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800
|
|
35
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
36
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
37
|
+
_JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3
|
|
38
|
+
_ERROR_MORE_DATA = 234
|
|
39
|
+
_ERROR_INVALID_PARAMETER = 87
|
|
40
|
+
_STILL_ACTIVE = 259
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _IoCounters(ctypes.Structure):
|
|
44
|
+
_fields_ = [
|
|
45
|
+
(name, ctypes.c_uint64)
|
|
46
|
+
for name in (
|
|
47
|
+
"ReadOperationCount",
|
|
48
|
+
"WriteOperationCount",
|
|
49
|
+
"OtherOperationCount",
|
|
50
|
+
"ReadTransferCount",
|
|
51
|
+
"WriteTransferCount",
|
|
52
|
+
"OtherTransferCount",
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _JobBasicLimitInformation(ctypes.Structure):
|
|
58
|
+
_fields_ = [
|
|
59
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
60
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
61
|
+
("LimitFlags", ctypes.c_uint32),
|
|
62
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
63
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
64
|
+
("ActiveProcessLimit", ctypes.c_uint32),
|
|
65
|
+
("Affinity", ctypes.c_size_t),
|
|
66
|
+
("PriorityClass", ctypes.c_uint32),
|
|
67
|
+
("SchedulingClass", ctypes.c_uint32),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _JobExtendedLimitInformation(ctypes.Structure):
|
|
72
|
+
_fields_ = [
|
|
73
|
+
("BasicLimitInformation", _JobBasicLimitInformation),
|
|
74
|
+
("IoInfo", _IoCounters),
|
|
75
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
76
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
77
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
78
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _JobProcessListHeader(ctypes.Structure):
|
|
83
|
+
_fields_ = [
|
|
84
|
+
("NumberOfAssignedProcesses", ctypes.c_uint32),
|
|
85
|
+
("NumberOfProcessIdsInList", ctypes.c_uint32),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _ProcessMemoryCounters(ctypes.Structure):
|
|
90
|
+
_fields_ = [
|
|
91
|
+
("cb", ctypes.c_uint32),
|
|
92
|
+
("PageFaultCount", ctypes.c_uint32),
|
|
93
|
+
*[
|
|
94
|
+
(name, ctypes.c_size_t)
|
|
95
|
+
for name in (
|
|
96
|
+
"PeakWorkingSetSize",
|
|
97
|
+
"WorkingSetSize",
|
|
98
|
+
"QuotaPeakPagedPoolUsage",
|
|
99
|
+
"QuotaPagedPoolUsage",
|
|
100
|
+
"QuotaPeakNonPagedPoolUsage",
|
|
101
|
+
"QuotaNonPagedPoolUsage",
|
|
102
|
+
"PagefileUsage",
|
|
103
|
+
"PeakPagefileUsage",
|
|
104
|
+
)
|
|
105
|
+
],
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _win_error(operation: str) -> RingoError:
|
|
110
|
+
error = ctypes.get_last_error() # type: ignore[attr-defined]
|
|
111
|
+
detail = ctypes.WinError(error) # type: ignore[attr-defined]
|
|
112
|
+
return RingoError(f"supervisor {operation} failed: {detail}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _WindowsJob:
|
|
116
|
+
def __init__(self, pid: int) -> None:
|
|
117
|
+
# Explicit pointer-sized return types are essential on 64-bit Windows.
|
|
118
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
|
119
|
+
self._api: Any = kernel32
|
|
120
|
+
kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
121
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
122
|
+
kernel32.SetHandleInformation.argtypes = (
|
|
123
|
+
wintypes.HANDLE,
|
|
124
|
+
wintypes.DWORD,
|
|
125
|
+
wintypes.DWORD,
|
|
126
|
+
)
|
|
127
|
+
kernel32.SetHandleInformation.restype = wintypes.BOOL
|
|
128
|
+
kernel32.SetInformationJobObject.argtypes = (
|
|
129
|
+
wintypes.HANDLE,
|
|
130
|
+
ctypes.c_int,
|
|
131
|
+
ctypes.c_void_p,
|
|
132
|
+
wintypes.DWORD,
|
|
133
|
+
)
|
|
134
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
135
|
+
kernel32.QueryInformationJobObject.argtypes = (
|
|
136
|
+
wintypes.HANDLE,
|
|
137
|
+
ctypes.c_int,
|
|
138
|
+
ctypes.c_void_p,
|
|
139
|
+
wintypes.DWORD,
|
|
140
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
141
|
+
)
|
|
142
|
+
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
|
|
143
|
+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
144
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
145
|
+
kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
146
|
+
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
147
|
+
kernel32.IsProcessInJob.argtypes = (
|
|
148
|
+
wintypes.HANDLE,
|
|
149
|
+
wintypes.HANDLE,
|
|
150
|
+
ctypes.POINTER(wintypes.BOOL),
|
|
151
|
+
)
|
|
152
|
+
kernel32.IsProcessInJob.restype = wintypes.BOOL
|
|
153
|
+
kernel32.GetExitCodeProcess.argtypes = (
|
|
154
|
+
wintypes.HANDLE,
|
|
155
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
156
|
+
)
|
|
157
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
158
|
+
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
159
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
160
|
+
kernel32.K32GetProcessMemoryInfo.argtypes = (
|
|
161
|
+
wintypes.HANDLE,
|
|
162
|
+
ctypes.POINTER(_ProcessMemoryCounters),
|
|
163
|
+
wintypes.DWORD,
|
|
164
|
+
)
|
|
165
|
+
kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
|
|
166
|
+
kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
167
|
+
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
168
|
+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
169
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
170
|
+
|
|
171
|
+
self._root_handle: int | None = None
|
|
172
|
+
self._resume_attempted = False
|
|
173
|
+
self._handle: int | None = kernel32.CreateJobObjectW(None, None)
|
|
174
|
+
if not self._handle:
|
|
175
|
+
raise _win_error("CreateJobObjectW")
|
|
176
|
+
try:
|
|
177
|
+
if not kernel32.SetHandleInformation(self._handle, 1, 0):
|
|
178
|
+
raise _win_error("SetHandleInformation(non-inheritable Job)")
|
|
179
|
+
limits = _JobExtendedLimitInformation()
|
|
180
|
+
limits.BasicLimitInformation.LimitFlags = (
|
|
181
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | _JOB_OBJECT_LIMIT_BREAKAWAY_OK
|
|
182
|
+
)
|
|
183
|
+
if not kernel32.SetInformationJobObject(
|
|
184
|
+
self._handle,
|
|
185
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
186
|
+
ctypes.byref(limits),
|
|
187
|
+
ctypes.sizeof(limits),
|
|
188
|
+
):
|
|
189
|
+
raise _win_error("SetInformationJobObject")
|
|
190
|
+
# Retain the exact non-inheritable root object, not a PID to reopen
|
|
191
|
+
# later. Assignment needs SET_QUOTA/TERMINATE; resume additionally
|
|
192
|
+
# needs SUSPEND_RESUME, membership inspection and SYNCHRONIZE.
|
|
193
|
+
self._root_handle = kernel32.OpenProcess(
|
|
194
|
+
0x0100 | 0x0001 | 0x0800 | 0x1000 | 0x00100000,
|
|
195
|
+
False,
|
|
196
|
+
pid,
|
|
197
|
+
)
|
|
198
|
+
if not self._root_handle:
|
|
199
|
+
self._root_handle = None
|
|
200
|
+
raise _win_error("OpenProcess(assign child)")
|
|
201
|
+
if not kernel32.AssignProcessToJobObject(self._handle, self._root_handle):
|
|
202
|
+
raise _win_error("AssignProcessToJobObject (no unguarded fallback)")
|
|
203
|
+
except BaseException:
|
|
204
|
+
self.close()
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
def _open_handle(self) -> int:
|
|
208
|
+
if self._handle is None:
|
|
209
|
+
raise RingoError("supervisor ProcessTree is closed")
|
|
210
|
+
return self._handle
|
|
211
|
+
|
|
212
|
+
def resume_root(self) -> None:
|
|
213
|
+
job = self._open_handle()
|
|
214
|
+
process = self._root_handle
|
|
215
|
+
if process is None:
|
|
216
|
+
raise RingoError("supervisor root process handle is closed")
|
|
217
|
+
if self._resume_attempted:
|
|
218
|
+
raise RingoError("supervisor root resume was already attempted")
|
|
219
|
+
self._resume_attempted = True
|
|
220
|
+
belongs = wintypes.BOOL()
|
|
221
|
+
if not self._api.IsProcessInJob(process, job, ctypes.byref(belongs)):
|
|
222
|
+
raise _win_error("IsProcessInJob(before root resume)")
|
|
223
|
+
if not belongs.value:
|
|
224
|
+
raise RingoError("supervisor root is not in the owned Job; refusing resume")
|
|
225
|
+
result = self._api.WaitForSingleObject(process, 0)
|
|
226
|
+
if result == 0xFFFFFFFF:
|
|
227
|
+
raise _win_error("WaitForSingleObject(before root resume)")
|
|
228
|
+
if result != 258: # WAIT_TIMEOUT: the owned process is still alive
|
|
229
|
+
raise RingoError("supervisor root exited before resume")
|
|
230
|
+
try:
|
|
231
|
+
native = ctypes.WinDLL("ntdll", use_last_error=True) # type: ignore[attr-defined]
|
|
232
|
+
resume = native.NtResumeProcess
|
|
233
|
+
resume.argtypes = (wintypes.HANDLE,)
|
|
234
|
+
resume.restype = wintypes.LONG # signed 32-bit NTSTATUS, not BOOL
|
|
235
|
+
except (OSError, AttributeError) as exc:
|
|
236
|
+
raise RingoError(
|
|
237
|
+
"supervisor NtResumeProcess unavailable; no resume fallback"
|
|
238
|
+
) from exc
|
|
239
|
+
status = int(resume(process))
|
|
240
|
+
if status != 0:
|
|
241
|
+
raise RingoError(
|
|
242
|
+
f"supervisor NtResumeProcess failed: NTSTATUS 0x{status & 0xFFFFFFFF:08X}; "
|
|
243
|
+
"no resume fallback"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _pids(self) -> list[int]:
|
|
247
|
+
handle = self._open_handle()
|
|
248
|
+
capacity = 16
|
|
249
|
+
header_size = ctypes.sizeof(_JobProcessListHeader)
|
|
250
|
+
for _ in range(12):
|
|
251
|
+
size = header_size + capacity * ctypes.sizeof(ctypes.c_size_t)
|
|
252
|
+
buffer = ctypes.create_string_buffer(size)
|
|
253
|
+
ok = self._api.QueryInformationJobObject(
|
|
254
|
+
handle,
|
|
255
|
+
_JOB_OBJECT_BASIC_PROCESS_ID_LIST,
|
|
256
|
+
buffer,
|
|
257
|
+
size,
|
|
258
|
+
None,
|
|
259
|
+
)
|
|
260
|
+
header = _JobProcessListHeader.from_buffer(buffer)
|
|
261
|
+
if ok:
|
|
262
|
+
count = int(header.NumberOfProcessIdsInList)
|
|
263
|
+
if count > capacity or count < int(header.NumberOfAssignedProcesses):
|
|
264
|
+
capacity = max(capacity * 2, int(header.NumberOfAssignedProcesses))
|
|
265
|
+
continue
|
|
266
|
+
pids = (ctypes.c_size_t * count).from_buffer(buffer, header_size)
|
|
267
|
+
return [int(pid) for pid in pids]
|
|
268
|
+
if ctypes.get_last_error() != _ERROR_MORE_DATA: # type: ignore[attr-defined]
|
|
269
|
+
raise _win_error("QueryInformationJobObject(process list)")
|
|
270
|
+
capacity = max(capacity * 2, int(header.NumberOfAssignedProcesses))
|
|
271
|
+
raise RingoError("supervisor Job process list did not stabilize")
|
|
272
|
+
|
|
273
|
+
def memory_bytes(self) -> int:
|
|
274
|
+
handle = self._open_handle()
|
|
275
|
+
total = 0
|
|
276
|
+
for pid in self._pids():
|
|
277
|
+
# A Job list snapshot can race exit/PID reuse. Read only a handle
|
|
278
|
+
# still belonging to THIS Job; never infer membership via parents.
|
|
279
|
+
process = self._api.OpenProcess(0x0400 | 0x0010, False, pid)
|
|
280
|
+
if not process:
|
|
281
|
+
if ctypes.get_last_error() == _ERROR_INVALID_PARAMETER: # type: ignore[attr-defined]
|
|
282
|
+
continue # exited between the snapshot and OpenProcess
|
|
283
|
+
raise _win_error("OpenProcess(read Job member RSS)")
|
|
284
|
+
try:
|
|
285
|
+
belongs = wintypes.BOOL()
|
|
286
|
+
if not self._api.IsProcessInJob(process, handle, ctypes.byref(belongs)):
|
|
287
|
+
raise _win_error("IsProcessInJob")
|
|
288
|
+
if not belongs.value:
|
|
289
|
+
continue # reused PID outside our Job
|
|
290
|
+
counters = _ProcessMemoryCounters()
|
|
291
|
+
counters.cb = ctypes.sizeof(counters)
|
|
292
|
+
if not self._api.K32GetProcessMemoryInfo(
|
|
293
|
+
process,
|
|
294
|
+
ctypes.byref(counters),
|
|
295
|
+
ctypes.sizeof(counters),
|
|
296
|
+
):
|
|
297
|
+
exit_code = wintypes.DWORD()
|
|
298
|
+
if (
|
|
299
|
+
self._api.GetExitCodeProcess(process, ctypes.byref(exit_code))
|
|
300
|
+
and exit_code.value != _STILL_ACTIVE
|
|
301
|
+
):
|
|
302
|
+
continue
|
|
303
|
+
raise _win_error("GetProcessMemoryInfo(Job member RSS)")
|
|
304
|
+
total += int(counters.WorkingSetSize)
|
|
305
|
+
finally:
|
|
306
|
+
self._api.CloseHandle(process)
|
|
307
|
+
return total
|
|
308
|
+
|
|
309
|
+
def kill(self) -> None:
|
|
310
|
+
if not self._api.TerminateJobObject(self._open_handle(), 1):
|
|
311
|
+
raise _win_error("TerminateJobObject")
|
|
312
|
+
|
|
313
|
+
def is_empty(self) -> bool:
|
|
314
|
+
return not self._pids()
|
|
315
|
+
|
|
316
|
+
def close(self) -> None:
|
|
317
|
+
error: RingoError | None = None
|
|
318
|
+
if self._handle is not None:
|
|
319
|
+
if self._api.CloseHandle(self._handle):
|
|
320
|
+
self._handle = None
|
|
321
|
+
else:
|
|
322
|
+
error = _win_error("CloseHandle(Job)")
|
|
323
|
+
if self._root_handle is not None:
|
|
324
|
+
if self._api.CloseHandle(self._root_handle):
|
|
325
|
+
self._root_handle = None
|
|
326
|
+
elif error is None:
|
|
327
|
+
error = _win_error("CloseHandle(root process)")
|
|
328
|
+
if error is not None:
|
|
329
|
+
raise error
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class _PosixGroup:
|
|
333
|
+
def __init__(self, pid: int) -> None:
|
|
334
|
+
try:
|
|
335
|
+
import psutil
|
|
336
|
+
except ImportError as exc:
|
|
337
|
+
raise RingoError(
|
|
338
|
+
"POSIX supervisor requires the optional ringo-task-queue[supervisor] extra"
|
|
339
|
+
) from exc
|
|
340
|
+
self._ps: Any = psutil
|
|
341
|
+
self._pgid = pid
|
|
342
|
+
self._sid = pid
|
|
343
|
+
self._closed = False
|
|
344
|
+
self._empty = False
|
|
345
|
+
try:
|
|
346
|
+
root = psutil.Process(pid)
|
|
347
|
+
self._root_created = root.create_time()
|
|
348
|
+
# These APIs are POSIX-only; the backend is never created on Windows.
|
|
349
|
+
if os.getpgid(pid) != pid or os.getsid(pid) != pid: # type: ignore[attr-defined]
|
|
350
|
+
raise RingoError(
|
|
351
|
+
"supervisor child must be started with start_new_session=True"
|
|
352
|
+
)
|
|
353
|
+
if not root.is_running():
|
|
354
|
+
raise RingoError("supervisor child exited before process group capture")
|
|
355
|
+
except (psutil.Error, OSError) as exc:
|
|
356
|
+
raise RingoError(
|
|
357
|
+
f"could not capture supervisor process group: {exc}"
|
|
358
|
+
) from exc
|
|
359
|
+
self._known: dict[int, Any] = {pid: root}
|
|
360
|
+
|
|
361
|
+
def _alive(self, process: Any) -> bool:
|
|
362
|
+
try:
|
|
363
|
+
# psutil guards signals and is_running() with creation time, not
|
|
364
|
+
# just kill(pid, 0). Zombies cannot execute or retain descendants.
|
|
365
|
+
return bool(
|
|
366
|
+
process.is_running() and process.status() != self._ps.STATUS_ZOMBIE
|
|
367
|
+
)
|
|
368
|
+
except (self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
369
|
+
return False
|
|
370
|
+
except self._ps.Error as exc:
|
|
371
|
+
raise RingoError(
|
|
372
|
+
f"could not verify supervisor member identity: {exc}"
|
|
373
|
+
) from exc
|
|
374
|
+
|
|
375
|
+
def _members(self) -> list[Any]:
|
|
376
|
+
if self._closed:
|
|
377
|
+
raise RingoError("supervisor ProcessTree is closed")
|
|
378
|
+
if self._empty:
|
|
379
|
+
return []
|
|
380
|
+
alive = {pid: proc for pid, proc in self._known.items() if self._alive(proc)}
|
|
381
|
+
anchors = set(alive)
|
|
382
|
+
candidates: dict[int, Any] = {}
|
|
383
|
+
# Inspect group/session membership, NOT the current root's children():
|
|
384
|
+
# reparented members must survive root exit in our ownership model.
|
|
385
|
+
for pid in self._ps.pids():
|
|
386
|
+
try:
|
|
387
|
+
if (
|
|
388
|
+
os.getpgid(pid) != self._pgid # type: ignore[attr-defined]
|
|
389
|
+
or os.getsid(pid) != self._sid # type: ignore[attr-defined]
|
|
390
|
+
):
|
|
391
|
+
continue
|
|
392
|
+
process = self._ps.Process(pid)
|
|
393
|
+
created = process.create_time()
|
|
394
|
+
if (
|
|
395
|
+
os.getpgid(pid) != self._pgid # type: ignore[attr-defined]
|
|
396
|
+
or os.getsid(pid) != self._sid # type: ignore[attr-defined]
|
|
397
|
+
):
|
|
398
|
+
continue
|
|
399
|
+
# Reject a recycled group leader even if our old group was
|
|
400
|
+
# never observed empty. Do not adopt its unrelated children.
|
|
401
|
+
if pid == self._pgid and created != self._root_created:
|
|
402
|
+
raise RingoError("supervisor process group identity was reused")
|
|
403
|
+
if created < self._root_created or not self._alive(process):
|
|
404
|
+
continue
|
|
405
|
+
candidates[pid] = process
|
|
406
|
+
except (ProcessLookupError, self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
407
|
+
continue
|
|
408
|
+
except (OSError, self._ps.Error) as exc:
|
|
409
|
+
raise RingoError(
|
|
410
|
+
f"could not inspect supervisor process group: {exc}"
|
|
411
|
+
) from exc
|
|
412
|
+
# An anchor must still be the same live identity, not just a PID found
|
|
413
|
+
# in an earlier snapshot. Without one, a PGID alone proves nothing.
|
|
414
|
+
anchored = any(
|
|
415
|
+
pid in candidates
|
|
416
|
+
and alive[pid].create_time() == candidates[pid].create_time()
|
|
417
|
+
and self._alive(alive[pid])
|
|
418
|
+
for pid in anchors
|
|
419
|
+
)
|
|
420
|
+
if candidates and not anchored:
|
|
421
|
+
raise RingoError(
|
|
422
|
+
"supervisor process group lost its verified identity anchors"
|
|
423
|
+
)
|
|
424
|
+
alive.update(candidates)
|
|
425
|
+
self._known = alive
|
|
426
|
+
if not alive:
|
|
427
|
+
self._empty = True # never re-adopt a subsequently recycled PGID
|
|
428
|
+
return list(alive.values())
|
|
429
|
+
|
|
430
|
+
def memory_bytes(self) -> int:
|
|
431
|
+
total = 0
|
|
432
|
+
for process in self._members():
|
|
433
|
+
try:
|
|
434
|
+
rss = int(process.memory_info().rss)
|
|
435
|
+
if self._alive(process):
|
|
436
|
+
total += rss
|
|
437
|
+
except (self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
438
|
+
continue
|
|
439
|
+
except self._ps.Error as exc:
|
|
440
|
+
raise RingoError(
|
|
441
|
+
f"could not read supervisor member RSS: {exc}"
|
|
442
|
+
) from exc
|
|
443
|
+
return total
|
|
444
|
+
|
|
445
|
+
def kill(self) -> None:
|
|
446
|
+
# Freeze verified identities first, then discover forks made during
|
|
447
|
+
# enumeration before killing. Never send a raw signal to a reused PGID.
|
|
448
|
+
stopped: dict[tuple[int, float], Any] = {}
|
|
449
|
+
try:
|
|
450
|
+
for _ in range(32):
|
|
451
|
+
members = self._members()
|
|
452
|
+
new = [p for p in members if (p.pid, p.create_time()) not in stopped]
|
|
453
|
+
for process in new:
|
|
454
|
+
try:
|
|
455
|
+
process.send_signal(signal.SIGSTOP) # type: ignore[attr-defined]
|
|
456
|
+
stopped[(process.pid, process.create_time())] = process
|
|
457
|
+
except (self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
458
|
+
continue
|
|
459
|
+
except self._ps.Error as exc:
|
|
460
|
+
raise RingoError(
|
|
461
|
+
f"could not stop supervisor member for kill: {exc}"
|
|
462
|
+
) from exc
|
|
463
|
+
# SIGSTOP delivery is asynchronous. A stable PID snapshot alone
|
|
464
|
+
# is insufficient: verify quiescence before the final rescan.
|
|
465
|
+
quiescent = True
|
|
466
|
+
for process in members:
|
|
467
|
+
try:
|
|
468
|
+
if self._alive(process) and process.status() not in (
|
|
469
|
+
self._ps.STATUS_STOPPED,
|
|
470
|
+
self._ps.STATUS_TRACING_STOP,
|
|
471
|
+
):
|
|
472
|
+
quiescent = False
|
|
473
|
+
except (self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
474
|
+
continue
|
|
475
|
+
except self._ps.Error as exc:
|
|
476
|
+
raise RingoError(
|
|
477
|
+
f"could not verify stopped supervisor member: {exc}"
|
|
478
|
+
) from exc
|
|
479
|
+
if not new and quiescent:
|
|
480
|
+
break
|
|
481
|
+
time.sleep(0.005)
|
|
482
|
+
else:
|
|
483
|
+
raise RingoError(
|
|
484
|
+
"supervisor process group did not quiesce during forced cleanup"
|
|
485
|
+
)
|
|
486
|
+
finally:
|
|
487
|
+
# Even when enumeration/permission checks fail, do not leave the
|
|
488
|
+
# identities we already stopped permanently suspended.
|
|
489
|
+
error: Exception | None = None
|
|
490
|
+
for process in stopped.values():
|
|
491
|
+
try:
|
|
492
|
+
process.kill() # psutil checks creation time before signalling
|
|
493
|
+
except (self._ps.NoSuchProcess, self._ps.ZombieProcess):
|
|
494
|
+
continue
|
|
495
|
+
except self._ps.Error as exc:
|
|
496
|
+
try:
|
|
497
|
+
process.send_signal(signal.SIGCONT) # type: ignore[attr-defined]
|
|
498
|
+
except self._ps.Error:
|
|
499
|
+
pass
|
|
500
|
+
error = RingoError(f"could not kill supervisor member: {exc}")
|
|
501
|
+
if error is not None:
|
|
502
|
+
raise error
|
|
503
|
+
|
|
504
|
+
def is_empty(self) -> bool:
|
|
505
|
+
return not self._members()
|
|
506
|
+
|
|
507
|
+
def close(self) -> None:
|
|
508
|
+
self._closed = True
|
|
509
|
+
self._known.clear()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class ProcessTree:
|
|
513
|
+
"""Own resources of an already-created, entrypoint-gated business child.
|
|
514
|
+
|
|
515
|
+
``memory_bytes`` sums RSS (shared pages may be counted more than once).
|
|
516
|
+
Access/inspection failures raise RingoError, never a fabricated zero/empty.
|
|
517
|
+
``close`` is idempotent; on Windows closing the last Job handle also kills
|
|
518
|
+
members. On POSIX the caller must explicitly kill/wait before close.
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
def __init__(self, pid: int) -> None:
|
|
522
|
+
if type(pid) is not int or pid <= 0 or pid == os.getpid():
|
|
523
|
+
raise RingoError("supervisor ProcessTree requires a positive child PID")
|
|
524
|
+
if os.name == "nt":
|
|
525
|
+
if pid > 0xFFFFFFFF:
|
|
526
|
+
raise RingoError("supervisor child PID exceeds the Windows DWORD range")
|
|
527
|
+
self._backend: _WindowsJob | _PosixGroup = _WindowsJob(pid)
|
|
528
|
+
else:
|
|
529
|
+
self._backend = _PosixGroup(pid)
|
|
530
|
+
|
|
531
|
+
def resume_root(self) -> None:
|
|
532
|
+
"""Resume an initially suspended Windows root once; a no-op on POSIX.
|
|
533
|
+
|
|
534
|
+
The caller must create it with CREATE_SUSPENDED. Any failure requires
|
|
535
|
+
ordinary kill/wait/close cleanup, never a second or unguarded resume.
|
|
536
|
+
"""
|
|
537
|
+
if isinstance(self._backend, _WindowsJob):
|
|
538
|
+
self._backend.resume_root()
|
|
539
|
+
|
|
540
|
+
def memory_bytes(self) -> int:
|
|
541
|
+
return self._backend.memory_bytes()
|
|
542
|
+
|
|
543
|
+
def kill(self) -> None:
|
|
544
|
+
self._backend.kill()
|
|
545
|
+
|
|
546
|
+
def wait_empty(self, timeout: float) -> bool:
|
|
547
|
+
if not math.isfinite(timeout) or timeout < 0:
|
|
548
|
+
raise ValueError("timeout must be finite and non-negative")
|
|
549
|
+
deadline = time.monotonic() + timeout
|
|
550
|
+
while True:
|
|
551
|
+
if self._backend.is_empty():
|
|
552
|
+
return True
|
|
553
|
+
remaining = deadline - time.monotonic()
|
|
554
|
+
if remaining <= 0:
|
|
555
|
+
return False
|
|
556
|
+
time.sleep(min(0.025, remaining))
|
|
557
|
+
|
|
558
|
+
def close(self) -> None:
|
|
559
|
+
self._backend.close()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
__all__ = ["ProcessTree"]
|