Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:

jobs:
test:
runs-on: windows-latest
runs-on: ubuntu-latest

steps:
- name: Checkout code
Expand All @@ -24,7 +24,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install .

- name: Run unit tests
run: |
Expand Down
37 changes: 19 additions & 18 deletions process_controller/controller.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import time
import psutil
import threading
import subprocess

class ProcessController():
"""
Expand Down Expand Up @@ -98,7 +97,7 @@ def is_running(self):
Returns whether the process is running.
"""
process = self.get_process()
return process is not None and process.is_running()
return process is not None and process.is_running() and process.status() != psutil.STATUS_ZOMBIE

def get_cpu_usage(self, interval=0.1):
"""
Expand All @@ -124,13 +123,18 @@ def close(self):
process = self.get_process()
if process is not None:
try:
process.terminate()
return True
process.terminate()
process.wait(timeout=5)
except psutil.NoSuchProcess:
return True
except (psutil.AccessDenied, psutil.TimeoutExpired, psutil.ZombieProcess, psutil.Error) as e:
except psutil.TimeoutExpired:
process.kill()
process.wait()
except (psutil.AccessDenied, psutil.ZombieProcess, psutil.Error) as e:
print(f"An error occurred: {e}")
return False
return False

return True

def terminate(self):
"""
Expand Down Expand Up @@ -161,30 +165,27 @@ def delayed_termination():

def restart(self):
"""
Restarts the process if it is running, terminating and then starting a new instance with the same command line and working directory.
Restarts the process if it is running, terminating and then starting a new instance with the same command line and working directory. Returns the new process or None.
"""
# If the process stopped before the restart, cancel restart attempt.
if not self.is_running():
return None

process = self.get_process()
if process:
try:
# Store cmdline before termination
cmdline = process.cmdline()
cwd = process.cwd()

# If the process stopped before the restart, cancel restart attempt.
if not process.is_running():
return False

self.terminate()

# Wait for thread to die
while self.is_running():
time.sleep(0.1)

# Start thread
new_process = psutil.Popen(cmdline, cwd=cwd)
self.pid = new_process.pid
self.create_time = new_process.create_time
return True
self.create_time = new_process.create_time()
return new_process
except Exception as e:
print(f"Error restarting process {self.pid}: {e}")
return False

return None
56 changes: 33 additions & 23 deletions tests/test_process_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
import psutil
import time
import os
import sys
import subprocess

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from process_controller import ProcessController

data_folder = os.path.join(os.path.dirname(__file__), 'data')
Expand Down Expand Up @@ -45,43 +42,50 @@ def setUp(self):
for process in psutil.process_iter(['cwd']):
if process.cwd == data_folder:
process.kill()
process.wait()

self.process = psutil.Popen(
self.cmdline,
cwd=data_folder
)

self.processes = [self.process]

self.process = subprocess.Popen(
self.cmdline,
cwd=data_folder,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
yield_until_process_running(self.process.pid)

# Create a ProcessController instance for the new process
process = psutil.Process(self.process.pid)
self.controller = ProcessController.from_process(process)

self.controller = ProcessController.from_process(self.process)

def tearDown(self):
"""
Terminate the process after each test.
Terminate the processes after each test.
"""
self.controller.terminate()
self.process.terminate()
while self.controller.is_running():
time.sleep(0.1)

for process in self.processes:
if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:


process.terminate()
# Ensure the process exits
try:
process.wait(timeout=5)
except psutil.TimeoutExpired:
process.kill()
process.wait()

# This is an extra measure to clean up POpen references (Also helps silence early warnings)
process.__exit__(None, None, None)

def test_find_process_1(self):
"""
Test to verify that the ProcessController can correctly find the running process based on filter parameters.
"""
filter = {
'name': 'python.exe',
'cmdline': self.cmdline
}

process_controllers = ProcessController.find_processes(filter)
self.assertTrue(
any([process.pid == self.process.pid for process in process_controllers]),
msg="test_process_controller.test_find_process_1: Process was not found with the filter name='python.exe' and cmdline."
msg="test_process_controller.test_find_process_1: Process was not found with the filter name='python' and cmdline."
)

def test_find_process_2(self):
Expand Down Expand Up @@ -165,9 +169,15 @@ def test_restart(self):
initial_pid = self.controller.pid
initial_create_time = self.controller.create_time

# This is added to allow create_time to differ from the setup process.
time.sleep(0.1)

# Restart the process
self.assertTrue(
self.controller.restart(),
new_process = self.controller.restart()
self.processes.append(new_process)

self.assertIsNotNone(
new_process,
msg="test_process_controller.test_restart: Process could not be restarted."
)

Expand Down