Skip to content

Commit a3e351b

Browse files
authored
fix: Improve handling of AnyBodyCon processes when on shutdown (#113)
* Ensure we return a defragmented DataFrame * fix: Improved the way AnyPyTools kills AnyBody on exits and on forced shutdown AnyPyTools now better handles shuting down running AnyBody processes when it user braks (ctrl-c) or it exists early. It also ties the anybodycon subprocesses to the main process using some Win CreateProcess tricks. So it the main python process is killed the AnyBodyCon proesses will be killed automatically * fix formatting * Move Windows only import * Update version number * use correct version number * Add changelog entry
1 parent 2badedc commit a3e351b

8 files changed

+2590
-754
lines changed

CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# AnyPyTools Change Log
22

3+
4+
## v1.12.0
5+
6+
**Added:**
7+
8+
* Added a way of controlling AnyBodyCon processes, which forces the process to
9+
automatically end wwhen the Python process ends. This prevents the need to
10+
manually close the AnyBodyCon processes if the parent process was force killed.
11+
12+
313
## v1.11.5
414

515
**Fixed:**

anypytools/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"NORMAL_PRIORITY_CLASS",
3737
]
3838

39-
__version__ = "1.11.6"
39+
__version__ = "1.12.0"
4040

4141

4242
def print_versions():

anypytools/abcutils.py

+41-62
Original file line numberDiff line numberDiff line change
@@ -6,47 +6,50 @@
66
@author: Morten
77
"""
88

9+
import atexit
10+
import collections
11+
import copy
12+
import ctypes
13+
import logging
914
import os
10-
import io
15+
import pathlib
16+
import shelve
1117
import sys
1218
import time
13-
import copy
1419
import types
15-
import ctypes
16-
import shelve
17-
import atexit
18-
import pathlib
19-
import logging
2020
import warnings
21-
import collections
22-
from pathlib import Path
23-
from subprocess import Popen, TimeoutExpired
2421
from contextlib import suppress
25-
from tempfile import NamedTemporaryFile
26-
from threading import Thread, RLock
22+
from pathlib import Path
2723
from queue import Queue
28-
24+
from subprocess import TimeoutExpired
25+
from tempfile import NamedTemporaryFile
26+
from threading import RLock, Thread
2927
from typing import Generator, List
3028

3129
import numpy as np
3230
from tqdm.auto import tqdm
3331

32+
from .macroutils import AnyMacro, MacroCommand
3433
from .tools import (
34+
BELOW_NORMAL_PRIORITY_CLASS,
3535
ON_WINDOWS,
36-
make_hash,
36+
AnyPyProcessOutput,
3737
AnyPyProcessOutputList,
38-
parse_anybodycon_output,
39-
getsubdirs,
38+
case_preserving_replace,
4039
get_anybodycon_path,
41-
BELOW_NORMAL_PRIORITY_CLASS,
42-
AnyPyProcessOutput,
43-
run_from_ipython,
4440
get_ncpu,
45-
winepath,
41+
getsubdirs,
42+
make_hash,
43+
parse_anybodycon_output,
4644
silentremove,
47-
case_preserving_replace,
45+
winepath,
4846
)
49-
from .macroutils import AnyMacro, MacroCommand
47+
48+
if ON_WINDOWS:
49+
from .jobpopen import JobPopen as Popen
50+
from subprocess import CREATE_NEW_PROCESS_GROUP
51+
else:
52+
from subprocess import Popen
5053

5154
logger = logging.getLogger("abt.anypytools")
5255

@@ -60,65 +63,40 @@
6063
class _SubProcessContainer(object):
6164
"""Class to hold a record of process pids from Popen.
6265
63-
Properties
64-
----------
65-
stop_all: boolean
66-
If set to True all process held by the object will be automatically
67-
killed
68-
6966
Methods
7067
-------
68+
stop_all():
69+
Kill all process held by the object
7170
add(pid):
7271
Add process id to the record of process
73-
7472
remove(pid):
7573
Remove process id from the record
7674
7775
"""
7876

7977
def __init__(self):
80-
self._pids = set()
81-
self._stop_all = False
78+
self._pids: set = set()
8279

8380
def add(self, pid):
8481
with _thread_lock:
8582
self._pids.add(pid)
86-
if self.stop_all:
87-
self._kill_running_processes()
8883

8984
def remove(self, pid):
9085
with _thread_lock:
91-
try:
92-
self._pids.remove(pid)
93-
except KeyError:
94-
pass
86+
self._pids.pop(pid, None)
9587

96-
@property
9788
def stop_all(self):
98-
return self._stop_all
99-
100-
@stop_all.setter
101-
def stop_all(self, value):
102-
if value:
103-
self._stop_all = True
104-
self._kill_running_processes()
105-
else:
106-
self._stop_all = False
107-
108-
def _kill_running_processes(self):
10989
"""Clean up and shut down any running processes."""
11090
# Kill any rouge processes that are still running.
11191
with _thread_lock:
112-
killed = []
11392
for pid in self._pids:
11493
with suppress(Exception):
11594
os.kill(pid, _KILLED_BY_ANYPYTOOLS)
116-
killed.append(str(pid))
11795
self._pids.clear()
11896

11997

12098
_subprocess_container = _SubProcessContainer()
121-
atexit.register(_subprocess_container._kill_running_processes)
99+
atexit.register(_subprocess_container.stop_all)
122100

123101

124102
def execute_anybodycon(
@@ -212,6 +190,7 @@ def execute_anybodycon(
212190
ctypes.windll.kernel32.SetErrorMode(SEM_NOGPFAULTERRORBOX)
213191
subprocess_flags = 0x8000000 # win32con.CREATE_NO_WINDOW?
214192
subprocess_flags |= priority
193+
subprocess_flags |= CREATE_NEW_PROCESS_GROUP
215194
extra_kwargs = {"creationflags": subprocess_flags}
216195

217196
anybodycmd = [
@@ -275,6 +254,7 @@ def execute_anybodycon(
275254
cwd=folder,
276255
)
277256

257+
retcode = None
278258
_subprocess_container.add(proc.pid)
279259
try:
280260
proc.wait(timeout=timeout)
@@ -283,13 +263,16 @@ def execute_anybodycon(
283263
proc.kill()
284264
proc.communicate()
285265
retcode = _TIMEDOUT_BY_ANYPYTOOLS
286-
except KeyboardInterrupt:
266+
except KeyboardInterrupt as e:
287267
proc.terminate()
288268
proc.communicate()
289269
retcode = _KILLED_BY_ANYPYTOOLS
290-
raise
270+
raise e
291271
finally:
292-
_subprocess_container.remove(proc.pid)
272+
if not retcode:
273+
proc.kill()
274+
else:
275+
_subprocess_container.remove(proc.pid)
293276

294277
if retcode == _TIMEDOUT_BY_ANYPYTOOLS:
295278
logfile.write(f"\nERROR: AnyPyTools : Timeout after {int(timeout)} sec.")
@@ -844,20 +827,17 @@ def start_macro(
844827
if hasattr(pbar, "container"):
845828
pbar.container.children[0].bar_style = "danger"
846829
pbar.update()
847-
except KeyboardInterrupt as e:
848-
_subprocess_container.stop_all = True
830+
except KeyboardInterrupt:
849831
tqdm.write("KeyboardInterrupt: User aborted")
850-
time.sleep(1)
851832
finally:
833+
_subprocess_container.stop_all()
852834
if not self.silent:
853835
tqdm.write(tasklist_summery(tasklist))
854836

855837
self.cleanup_logfiles(tasklist)
856838
# Cache the processed tasklist for restarting later
857839
self.cached_tasklist = tasklist
858-
# self.summery.final_summery(process_time, tasklist)
859-
task_output = [task.get_output() for task in tasklist]
860-
return AnyPyProcessOutputList(task_output)
840+
return AnyPyProcessOutputList(t.get_output() for t in tasklist)
861841

862842
def _worker(self, task, task_queue):
863843
"""Handle processing of the tasks."""
@@ -935,7 +915,6 @@ def _worker(self, task, task_queue):
935915
task_queue.put(task)
936916

937917
def _schedule_processes(self, tasklist) -> Generator[_Task, None, None]:
938-
_subprocess_container.stop_all = False
939918
# Make a shallow copy of the task list,
940919
# so we don't mess with the callers list.
941920
tasklist = copy.copy(tasklist)

anypytools/jobpopen.py

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# coding: utf-8
2+
"""
3+
Adaapted from https://stackoverflow.com/a/56632466
4+
5+
This module provides a JobPopen class that is a subclass of the Popen class from the subprocess module.
6+
7+
"""
8+
9+
import subprocess
10+
from subprocess import Popen
11+
12+
import win32api
13+
import win32job
14+
import win32process
15+
16+
17+
class JobPopen(Popen):
18+
"""Start a process in a new Win32 job object.
19+
20+
This `subprocess.Popen` subclass takes the same arguments as Popen and
21+
behaves the same way. In addition to that, created processes will be
22+
assigned to a new anonymous Win32 job object on startup, which will
23+
guarantee that the processes will be terminated by the OS as soon as
24+
either the Popen object, job object handle or parent Python process are
25+
closed.
26+
"""
27+
28+
class _winapijobhandler(object):
29+
"""Patches the native CreateProcess function in the subprocess module
30+
to assign created threads to the given job"""
31+
32+
def __init__(self, oldapi, job):
33+
self._oldapi = oldapi
34+
self._job = job
35+
36+
def __getattr__(self, key):
37+
if key != "CreateProcess":
38+
return getattr(self._oldapi, key) # Any other function is run as before
39+
else:
40+
return self.CreateProcess # CreateProcess will call the function below
41+
42+
def CreateProcess(self, *args, **kwargs):
43+
hp, ht, pid, tid = self._oldapi.CreateProcess(*args, **kwargs)
44+
win32job.AssignProcessToJobObject(self._job, hp)
45+
win32process.ResumeThread(ht)
46+
return hp, ht, pid, tid
47+
48+
def __init__(self, *args, **kwargs):
49+
"""Start a new process using an anonymous job object. Takes the same arguments as Popen"""
50+
51+
# Create a new job object
52+
self._win32_job = self._create_job_object()
53+
54+
# Temporarily patch the subprocess creation logic to assign created
55+
# processes to the new job, then resume execution normally.
56+
CREATE_SUSPENDED = 0x00000004
57+
kwargs.setdefault("creationflags", 0)
58+
kwargs["creationflags"] |= CREATE_SUSPENDED
59+
_winapi = subprocess._winapi # Python 3
60+
_winapi_key = "_winapi"
61+
try:
62+
setattr(
63+
subprocess,
64+
_winapi_key,
65+
JobPopen._winapijobhandler(_winapi, self._win32_job),
66+
)
67+
super(JobPopen, self).__init__(*args, **kwargs)
68+
finally:
69+
setattr(subprocess, _winapi_key, _winapi)
70+
71+
def _create_job_object(self):
72+
"""Create a new anonymous job object"""
73+
hjob = win32job.CreateJobObject(None, "")
74+
extended_info = win32job.QueryInformationJobObject(
75+
hjob, win32job.JobObjectExtendedLimitInformation
76+
)
77+
extended_info["BasicLimitInformation"][
78+
"LimitFlags"
79+
] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
80+
win32job.SetInformationJobObject(
81+
hjob, win32job.JobObjectExtendedLimitInformation, extended_info
82+
)
83+
return hjob
84+
85+
def _close_job_object(self, hjob):
86+
"""Close the handle to a job object, terminating all processes inside it"""
87+
if self._win32_job:
88+
win32api.CloseHandle(self._win32_job)
89+
self._win32_job = None
90+
91+
# This ensures that no remaining subprocesses are found when the process
92+
# exits from a `with JobPopen(...)` block.
93+
def __exit__(self, exc_type, value, traceback):
94+
super(JobPopen, self).__exit__(exc_type, value, traceback)
95+
self._close_job_object(self._win32_job)
96+
97+
# Python does not keep a reference outside of the parent class when the
98+
# interpreter exits, which is why we keep it here.
99+
_Popen = subprocess.Popen
100+
101+
def __del__(self):
102+
self._Popen.__del__(self)
103+
self._close_job_object(self._win32_job)

anypytools/tools.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -826,8 +826,8 @@ def to_dataframe(
826826
dfout[time_columns] = dfout[time_columns].interpolate(interp_method)
827827
dfout[constant_columns] = dfout[constant_columns].bfill()
828828

829-
dfout = dfout.loc[interp_val]
830-
dfout.reset_index(inplace=True)
829+
dfout = dfout.loc[interp_val].copy()
830+
dfout = dfout.reset_index()
831831

832832
return dfout
833833

0 commit comments

Comments
 (0)