ringo-task-queue 0.1.0.dev6__py3-none-win_amd64.whl → 0.1.0.dev8__py3-none-win_amd64.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.
@@ -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"]