Skip to content

Commit

Permalink
feat: force pykiso to close if threads are blocking (#490)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pog3k authored Jul 24, 2024
1 parent 52679d1 commit 7855293
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 2 deletions.
36 changes: 36 additions & 0 deletions src/pykiso/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
"""
import logging
import os
import pprint
import sys
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple

Expand All @@ -34,6 +37,8 @@
from .test_setup.config_registry import ConfigRegistry
from .types import PathType

UNRESOLVED_THREAD_TIMEOUT = 10


def eval_user_tags(click_context: click.Context) -> Dict[str, List[str]]:
"""Evaluate commandline args for user tags and raise exceptions for invalid
Expand Down Expand Up @@ -84,6 +89,35 @@ def check_file_extension(click_context: click.Context, param: click.Parameter, p
return paths


def active_threads() -> list[str]:
"""Get the names of all active threads except the main thread."""
return [thread.name for thread in threading.enumerate()][1:]


def check_and_handle_unresolved_threads(log: logging.Logger, timeout: int = 10) -> None:
"""Check if there are unresolved threads and handle them.
Process for unresolved threads:
- If there are unresolved threads, log a warning and wait for a timeout.
- If the threads are still running after the timeout, log a fatal error and force exit.
- If the threads are properly shut down, log a warning and exit normally.
"""
# Skip main thread and get running threads
running_threads = active_threads()

if len(running_threads) > 0:
for thread in running_threads:
log.warning(f"Unresolved thread {thread} is still running")
log.warning(f"Wait {timeout}s for unresolved threads to be terminated.")
time.sleep(timeout)
if threading.active_count() > 1:
log.fatal(
f"Unresolved threads {', '.join(active_threads())} are still running after {timeout} seconds. Force pykiso to Exit."
)
os._exit(test_execution.ExitCode.UNRESOLVED_THREADS)
else:
log.warning("Unresolved threads has been properly shut down. Normal exit.")


class CommandWithOptionalFlagValues(click.Command):
"""Custom command that allows specifying flags with a value, e.g. ``pykiso -c config.yaml --junit=./reports``."""

Expand Down Expand Up @@ -271,4 +305,6 @@ def main(
if isinstance(handler, logging.FileHandler):
logging.getLogger().removeHandler(handler)

check_and_handle_unresolved_threads(log, timeout=UNRESOLVED_THREAD_TIMEOUT)

sys.exit(exit_code)
1 change: 1 addition & 0 deletions src/pykiso/test_coordinator/test_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class ExitCode(enum.IntEnum):
ONE_OR_MORE_TESTS_FAILED_AND_RAISED_UNEXPECTED_EXCEPTION = 3
AUXILIARY_CREATION_FAILED = 4
BAD_CLI_USAGE = 5
UNRESOLVED_THREADS = 6


def create_test_suite(
Expand Down
40 changes: 38 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
# SPDX-License-Identifier: EPL-2.0
##########################################################################

from pathlib import Path

import click
import pytest
from click.testing import CliRunner
Expand Down Expand Up @@ -106,3 +104,41 @@ def test_check_file_extension(mocker):
paths = ("./complete/success.yaml", "./success.yml")
actual = cli.check_file_extension(click_context_mock, click_param_mock, paths)
assert actual == paths

def test_check_and_handle_unresolved_threads_no_unresolved_threads(mocker):
log_mock = mocker.MagicMock()
mocker.patch("pykiso.cli.active_threads", return_value=[])
cli.check_and_handle_unresolved_threads(log_mock)
log_mock.warning.assert_not_called()
log_mock.fatal.assert_not_called()

def test_check_and_handle_unresolved_threads_with_unresolved_threads_resolved_before_timeout(mocker):
log_mock = mocker.MagicMock()
mocker.patch("pykiso.cli.active_threads", side_effect=[["Thread-1"], []])
mocker.patch("time.sleep", return_value=None)
cli.check_and_handle_unresolved_threads(log_mock, timeout=5)
log_mock.warning.assert_called()
log_mock.fatal.assert_not_called()

def test_check_and_handle_unresolved_threads_with_unresolved_threads_not_resolved_after_timeout(mocker):
log_mock = mocker.MagicMock()
mocker.patch("pykiso.cli.active_threads", return_value=["Thread-1"])
mocker.patch("threading.active_count", return_value=2)
mocker.patch("time.sleep", return_value=None)
os_mock = mocker.patch("os._exit", return_value=None)
cli.check_and_handle_unresolved_threads(log_mock, timeout=5)
log_mock.warning.assert_called()
log_mock.fatal.assert_called()
os_mock.assert_called_with(cli.test_execution.ExitCode.UNRESOLVED_THREADS)

def test_active_threads(mocker):
"""Get the names of all active threads except the main thread."""
main_thread = mocker.MagicMock()
main_thread.configure_mock(name="Thread-Main")

other_thread = mocker.MagicMock()
other_thread.configure_mock(name="Thread-1")

mocker.patch("threading.enumerate", return_value= [main_thread, other_thread])
actual = cli.active_threads()
assert actual == ["Thread-1"]

0 comments on commit 7855293

Please sign in to comment.