Skip to content

Commit 75f8812

Browse files
committed
Create _safe_wait_procs() to handle AccessDenied errors
This has been seen on Windows and should be handled.
1 parent 972a635 commit 75f8812

File tree

2 files changed

+116
-7
lines changed

2 files changed

+116
-7
lines changed

src/ffpuppet/process_tree.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from platform import system
1010
from subprocess import Popen
1111
from time import perf_counter, sleep
12-
from typing import Generator, List, Optional, Tuple
12+
from typing import Callable, Generator, Iterable, List, Optional, Tuple, cast
1313

1414
try:
1515
from signal import SIGUSR1, Signals
@@ -40,6 +40,52 @@ def _last_modified(scan_dir: Path) -> Optional[float]:
4040
return None
4141

4242

43+
def _safe_wait_procs(
44+
procs: Iterable[Process],
45+
timeout: Optional[float] = 0,
46+
callback: Optional[Callable[[Process], object]] = None,
47+
) -> Tuple[List[Process], List[Process]]:
48+
"""Wrapper for psutil.wait_procs() to avoid AccessDenied.
49+
This can be an issue on Windows.
50+
51+
Args:
52+
See psutil.wait_procs().
53+
54+
Returns:
55+
See psutil.wait_procs().
56+
"""
57+
assert timeout is None or timeout >= 0
58+
59+
deadline = None if timeout is None else perf_counter() + timeout
60+
while True:
61+
remaining = None if deadline is None else max(deadline - perf_counter(), 0)
62+
try:
63+
return cast(
64+
Tuple[List[Process], List[Process]],
65+
wait_procs(procs, timeout=remaining, callback=callback),
66+
)
67+
except AccessDenied:
68+
pass
69+
if deadline is not None and deadline <= perf_counter():
70+
break
71+
sleep(0.25)
72+
73+
# manually check processes
74+
alive: List[Process] = []
75+
gone: List[Process] = []
76+
for proc in procs:
77+
try:
78+
if not proc.is_running():
79+
gone.append(proc)
80+
else:
81+
alive.append(proc)
82+
except AccessDenied:
83+
alive.append(proc)
84+
except NoSuchProcess:
85+
gone.append(proc)
86+
return (gone, alive)
87+
88+
4389
def _writing_coverage(procs: List[Process]) -> bool:
4490
"""Check if any processes have open .gcda files.
4591
@@ -275,7 +321,7 @@ def terminate(self) -> None:
275321
self.parent.wait(timeout=10)
276322
except (AccessDenied, NoSuchProcess, TimeoutExpired): # pragma: no cover
277323
pass
278-
procs = wait_procs(procs, timeout=0)[1]
324+
procs = _safe_wait_procs(procs, timeout=0)[1]
279325

280326
use_kill = False
281327
while procs:
@@ -291,7 +337,7 @@ def terminate(self) -> None:
291337
except (AccessDenied, NoSuchProcess): # pragma: no cover
292338
pass
293339
# wait for processes to terminate
294-
procs = wait_procs(procs, timeout=30)[1]
340+
procs = _safe_wait_procs(procs, timeout=30)[1]
295341
if use_kill:
296342
break
297343
use_kill = True
@@ -316,7 +362,7 @@ def wait(self, timeout: int = 300) -> int:
316362
"""
317363
try:
318364
exit_code = self.parent.wait(timeout=timeout) or 0
319-
except NoSuchProcess: # pragma: no cover
365+
except (AccessDenied, NoSuchProcess): # pragma: no cover
320366
# this is triggered sometimes when the process goes away
321367
exit_code = 0
322368
return exit_code
@@ -330,4 +376,4 @@ def wait_procs(self, timeout: Optional[float] = 0) -> int:
330376
Returns:
331377
Number of processes still alive.
332378
"""
333-
return len(wait_procs(self.processes(), timeout=timeout)[1])
379+
return len(_safe_wait_procs(self.processes(), timeout=timeout)[1])

src/ffpuppet/test_process_tree.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
from pathlib import Path
99
from subprocess import Popen
1010
from time import sleep
11+
from unittest import mock
1112

12-
from psutil import NoSuchProcess, Process, TimeoutExpired
13+
from psutil import AccessDenied, NoSuchProcess, Process, TimeoutExpired
1314
from pytest import mark, raises
1415

1516
from .exceptions import TerminateError
16-
from .process_tree import ProcessTree, _last_modified, _writing_coverage
17+
from .process_tree import (
18+
ProcessTree,
19+
_last_modified,
20+
_safe_wait_procs,
21+
_writing_coverage,
22+
)
1723

1824
TREE = Path(__file__).parent / "resources" / "tree.py"
1925

@@ -245,3 +251,60 @@ def test_writing_coverage_01(mocker):
245251
proc.open_files.return_value = (openfile("file.gcda", None),)
246252
assert _writing_coverage([proc])
247253
assert proc.open_files.call_count == 1
254+
255+
256+
@mark.parametrize(
257+
"wait_side_effect, procs, alive_count, gone_count",
258+
[
259+
# no processes - passthrough
260+
((([], []),), [], 0, 0),
261+
# AccessDenied - no procs
262+
(AccessDenied(), [], 0, 0),
263+
# AccessDenied - alive (is_running check)
264+
(
265+
AccessDenied(),
266+
[mock.Mock(spec_set=Process, is_running=mock.Mock(return_value=True))],
267+
1,
268+
0,
269+
),
270+
# AccessDenied - gone (is_running check)
271+
(
272+
AccessDenied(),
273+
[mock.Mock(spec_set=Process, is_running=mock.Mock(return_value=False))],
274+
0,
275+
1,
276+
),
277+
# AccessDenied - alive
278+
(
279+
AccessDenied(),
280+
[
281+
mock.Mock(
282+
spec_set=Process, is_running=mock.Mock(side_effect=AccessDenied())
283+
)
284+
],
285+
1,
286+
0,
287+
),
288+
# AccessDenied - gone
289+
(
290+
AccessDenied(),
291+
[
292+
mock.Mock(
293+
spec_set=Process,
294+
is_running=mock.Mock(side_effect=NoSuchProcess(pid=1)),
295+
)
296+
],
297+
0,
298+
1,
299+
),
300+
],
301+
)
302+
def test_safe_wait_procs_01(mocker, wait_side_effect, procs, alive_count, gone_count):
303+
"""test ProcessTree._safe_wait_procs()"""
304+
mocker.patch("ffpuppet.process_tree.perf_counter", side_effect=count(step=0.25))
305+
mocker.patch("ffpuppet.process_tree.sleep", autospec=True)
306+
mocker.patch("ffpuppet.process_tree.wait_procs", side_effect=wait_side_effect)
307+
308+
result = _safe_wait_procs(procs, timeout=1)
309+
assert len(result[0]) == gone_count
310+
assert len(result[1]) == alive_count

0 commit comments

Comments
 (0)