Skip to content

Commit

Permalink
Test: Common Crash Locations in Task
Browse files Browse the repository at this point in the history
  • Loading branch information
davidfstr committed Feb 10, 2024
1 parent bcdb415 commit d35d80f
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 33 deletions.
2 changes: 2 additions & 0 deletions src/crystal/tests/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
test_server,
test_shell,
test_ssd,
test_task_crashes,
test_tasks,
test_tasktree,
test_workflows,
Expand Down Expand Up @@ -85,6 +86,7 @@ def _test_functions_in_module(mod) -> List[Callable]:
_test_functions_in_module(test_server) +
_test_functions_in_module(test_shell) +
_test_functions_in_module(test_ssd) +
_test_functions_in_module(test_task_crashes) +
_test_functions_in_module(test_tasks) +
_test_functions_in_module(test_tasktree) +
_test_functions_in_module(test_workflows) +
Expand Down
229 changes: 229 additions & 0 deletions src/crystal/tests/test_task_crashes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
from crystal.task import (
DownloadResourceGroupTask, DownloadResourceGroupMembersTask, DownloadResourceTask,
UpdateResourceGroupMembersTask
)
from crystal.tests.util.server import served_project
from crystal.tests.util.tasks import (
clear_top_level_tasks_on_exit, scheduler_disabled
)
from crystal.tests.util.wait import wait_for
from crystal.tests.util.windows import OpenOrCreateDialog
from crystal.model import Project, Resource, ResourceGroup
from crystal.util.xfutures import Future
from crystal.util.xthreading import bg_call_later
from typing import Callable, Optional, TypeVar
from unittest.mock import patch


_R = TypeVar('_R')


# ------------------------------------------------------------------------------
# Test: Common Crash Locations in Task
#
# Below, some abbreviations are used in test names:
# - T = Task
# - DRT = DownloadResourceTask
# - DRGMT = DownloadResourceGroupMembersTask
# - DRGT = DownloadResourceGroupTask

async def test_when_T_try_get_next_task_unit_crashes_then_T_displays_as_crashed() -> None:
with scheduler_disabled, served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
home_url = sp.get_request_url('https://xkcd.com/')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
assert project is not None

with clear_top_level_tasks_on_exit(project):
# Create DownloadResourceTask
home_r = Resource(project, home_url)
home_r.download()
(download_r_task,) = project.root_task.children
assert isinstance(download_r_task, DownloadResourceTask)

# Force DownloadResourceTask into an illegal state
# (i.e. a container task with empty children)
# which should crash the next call to DRG.try_get_next_task_unit()
assert len(download_r_task.children) > 0
download_r_task._children = []

# Precondition
assert download_r_task.crash_reason is None

unit = project.root_task.try_get_next_task_unit() # step scheduler
assert unit is None

# Postcondition
assert download_r_task.crash_reason is not None


async def test_when_DRT_child_task_did_complete_event_crashes_then_DRT_displays_as_crashed() -> None:
with scheduler_disabled, served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
home_url = sp.get_request_url('https://xkcd.com/')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
assert project is not None

with clear_top_level_tasks_on_exit(project):
# Create DownloadResourceTask
home_r = Resource(project, home_url)
home_r.download()
(download_r_task,) = project.root_task.children
assert isinstance(download_r_task, DownloadResourceTask)

# Precondition
assert download_r_task.crash_reason is None

# Download URL
unit = project.root_task.try_get_next_task_unit() # step scheduler
assert unit is not None
await _bg_call_and_wait(unit)

# Parse links
unit = project.root_task.try_get_next_task_unit() # step scheduler
assert unit is not None
# Patch urljoin() to simulate effect of calling urlparse('//[oops'),
# which raises an exception in stock Python:
# https://discuss.python.org/t/urlparse-can-sometimes-raise-an-exception-should-it/44465
#
# NOTE: Overrides the fix in commit 5aaaba57076d537a4872bb3cf7270112ca497a06,
# reintroducing the related bug it fixed.
with patch('crystal.task.urljoin', sideeffect=ValueError('Invalid IPv6 URL')):
await _bg_call_and_wait(unit)

# Postcondition:
# Ensure crashed in DownloadResourceTask.child_task_did_complete(),
# when tried to resolve relative_urls from links parsed by
# ParseResourceRevisionLinks to absolute URLs
assert download_r_task.crash_reason is not None


async def test_when_DRGMT_load_children_crashes_then_DRGT_displays_as_crashed() -> None:
with scheduler_disabled, served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
atom_feed_url = sp.get_request_url('https://xkcd.com/atom.xml')
rss_feed_url = sp.get_request_url('https://xkcd.com/rss.xml')
feed_pattern = sp.get_request_url('https://xkcd.com/*.xml')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
assert project is not None

with clear_top_level_tasks_on_exit(project):
atom_feed_r = Resource(project, atom_feed_url)
rss_feed_r = Resource(project, rss_feed_url)

# Create DownloadResourceGroupMembersTask
feed_g = ResourceGroup(project, '', feed_pattern, source=None)
feed_g.download()
(download_rg_task,) = project.root_task.children
assert isinstance(download_rg_task, DownloadResourceGroupTask)
(update_rg_members_task, download_rg_members_task) = download_rg_task.children
assert isinstance(update_rg_members_task, UpdateResourceGroupMembersTask)
assert isinstance(download_rg_members_task, DownloadResourceGroupMembersTask)

# Capture download_rg_members_task.{_pdb, _children_loaded, subtitle}
# after exiting DownloadResourceGroupMembersTask.__init__()
pbc_after_init = download_rg_members_task._pbc
children_loaded_after_init = download_rg_members_task._children_loaded
subtitle_after_init = download_rg_members_task.subtitle
assert None == pbc_after_init
assert False == children_loaded_after_init
assert 'Queued' == subtitle_after_init

super_initialize_children = download_rg_members_task.initialize_children
def initialize_children(self, *args, **kwargs):
# Restore self.{_pdb, _children_loaded, subtitle} to original state
# after exiting DownloadResourceGroupMembersTask.__init__()
self._pbc = pbc_after_init
self._children_loaded = children_loaded_after_init
self.subtitle = subtitle_after_init

return super_initialize_children(*args, **kwargs)

# Precondition
assert download_rg_members_task.crash_reason is None

# Load children of DownloadResourceGroupMembersTask
unit = project.root_task.try_get_next_task_unit() # step scheduler
assert unit is not None
# Patch DownloadResourceGroupMembersTask.initialize_children() to reintroduce
# a bug in DownloadResourceGroupMembersTask._load_children() that was
# fixed in commit 44f5bd429201972d324df1287e673ddef9ffa936
with patch.object(download_rg_members_task, 'initialize_children', initialize_children):
await _bg_call_and_wait(unit)

# Postcondition
assert download_rg_members_task.crash_reason is not None


async def test_when_DRGMT_group_did_add_member_event_crashes_then_DRGT_displays_as_crashed() -> None:
with scheduler_disabled, served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
atom_feed_url = sp.get_request_url('https://xkcd.com/atom.xml')
rss_feed_url = sp.get_request_url('https://xkcd.com/rss.xml')
feed_pattern = sp.get_request_url('https://xkcd.com/*.xml')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
assert project is not None

with clear_top_level_tasks_on_exit(project):
atom_feed_r = Resource(project, atom_feed_url)

# Create DownloadResourceGroupMembersTask
feed_g = ResourceGroup(project, '', feed_pattern, source=None)
feed_g.download()
(download_rg_task,) = project.root_task.children
assert isinstance(download_rg_task, DownloadResourceGroupTask)
(update_rg_members_task, download_rg_members_task) = download_rg_task.children
assert isinstance(update_rg_members_task, UpdateResourceGroupMembersTask)
assert isinstance(download_rg_members_task, DownloadResourceGroupMembersTask)

super_notify_did_append_child = download_rg_members_task.notify_did_append_child
def notify_did_append_child(self, *args, **kwargs):
super_notify_did_append_child(*args, **kwargs)

# Simulate failure of `self._pbc.total += 1`
# due to self._pbc being None
raise AttributeError("'NoneType' object has no attribute 'total'")

# Precondition
assert download_rg_members_task.crash_reason is None

with patch.object(download_rg_members_task, 'notify_did_append_child', notify_did_append_child):
rss_feed_r = Resource(project, rss_feed_url)

# Postcondition
assert download_rg_members_task.crash_reason is not None


# ------------------------------------------------------------------------------
# Utility

async def _bg_call_and_wait(callable: Callable[[], _R], *, timeout: Optional[float]=None) -> _R:
"""
Start the specified callable on a background thread and
waits for it to finish running.
The foreground thread IS released while waiting, so the callable can safely
make calls to fg_call_later() and fg_call_and_wait() without deadlocking.
"""
result_cell = Future() # type: Future[_R]
def bg_task() -> None:
result_cell.set_running_or_notify_cancel()
try:
result_cell.set_result(callable())
except BaseException as e:
result_cell.set_exception(e)
bg_call_later(bg_task)
# NOTE: Releases foreground thread while waiting
await wait_for(lambda: result_cell.done() or None, timeout)

Check warning on line 225 in src/crystal/tests/test_task_crashes.py

View workflow job for this annotation

GitHub Actions / build-windows (windows-latest, 3.8)

Soft timeout exceeded (2.1s > 2.0s). <function _bg_call_and_wait.<locals>.<lambda> at 0x00000181E5E45D30>

Check warning on line 225 in src/crystal/tests/test_task_crashes.py

View workflow job for this annotation

GitHub Actions / build-windows (windows-latest, 3.9)

Soft timeout exceeded (2.1s > 2.0s). <function _bg_call_and_wait.<locals>.<lambda> at 0x00000196C1C8C550>
return result_cell.result()


# ------------------------------------------------------------------------------
14 changes: 4 additions & 10 deletions src/crystal/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
from crystal.task import (
ASSUME_RESOURCES_DOWNLOADED_IN_SESSION_WILL_ALWAYS_REMAIN_FRESH,
ProjectFreeSpaceTooLowError, Task
ProjectFreeSpaceTooLowError, Task,
)
from crystal.tests.util.asserts import *
from crystal.tests.util.data import (
MAX_TIME_TO_DOWNLOAD_404_URL,
MAX_TIME_TO_DOWNLOAD_XKCD_HOME_URL_BODY
)
from crystal.tests.util.downloads import load_children_of_drg_task
from crystal.tests.util.runner import bg_sleep
from crystal.tests.util.screenshots import screenshot_if_raises
from crystal.tests.util.server import served_project
from crystal.tests.util.skip import skipTest
from crystal.tests.util.subtests import SubtestsContext, awith_subtests
from crystal.tests.util.wait import wait_for
from crystal.tests.util.windows import OpenOrCreateDialog
from crystal.model import Project, Resource, ResourceGroup, RootResource
from crystal.model import Project, Resource, ResourceGroup
from crystal.util.progress import ProgressBarCalculator
from crystal.util.xcollections.lazy import AppendableLazySequence
import tempfile
from tqdm import tqdm
from typing import NamedTuple
from unittest import skip
from unittest.mock import patch, Mock, PropertyMock
Expand Down Expand Up @@ -207,8 +203,7 @@ async def try_download_with_disk_usage(du: _DiskUsage, *, expect_failure: bool)
with subtests.test('given project on small disk and less than 5 percent of disk free'):
with served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
if True:
home_url = sp.get_request_url('https://xkcd.com/')
home_url = sp.get_request_url('https://xkcd.com/')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
Expand All @@ -225,8 +220,7 @@ async def try_download_with_disk_usage(du: _DiskUsage, *, expect_failure: bool)
with subtests.test('given project on large disk and less than 4 gib of disk free'):
with served_project('testdata_xkcd.crystalproj.zip') as sp:
# Define URLs
if True:
home_url = sp.get_request_url('https://xkcd.com/')
home_url = sp.get_request_url('https://xkcd.com/')

async with (await OpenOrCreateDialog.wait_for()).create() as (mw, _):
project = Project._last_opened_project
Expand Down
27 changes: 5 additions & 22 deletions src/crystal/tests/test_tasktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from crystal.tests.util.data import MAX_TIME_TO_DOWNLOAD_404_URL
from crystal.tests.util.downloads import load_children_of_drg_task
from crystal.tests.util.server import served_project
from crystal.tests.util.tasks import (
mark_as_complete as _mark_as_complete,
scheduler_disabled,
)
from crystal.tests.util.wait import tree_has_no_children_condition, wait_for, wait_while
from crystal.tests.util.windows import MainWindow, OpenOrCreateDialog
from crystal.ui.tree2 import NodeView
Expand Down Expand Up @@ -560,7 +564,7 @@ async def _project_with_resource_group_starting_to_download(
raise ValueError()

scheduler_patched = (
patch('crystal.task.start_schedule_forever', lambda task: None)
scheduler_disabled
if not scheduler_thread_enabled
else cast(AbstractContextManager, nullcontext())
) # type: AbstractContextManager
Expand Down Expand Up @@ -745,24 +749,3 @@ def _value_of_more_node(tn: NodeView) -> Optional[int]:

def _is_complete(tn: NodeView) -> bool:
return tn.subtitle == 'Complete'


def _mark_as_complete(task: Task) -> None:
assert not task.complete
task.finish()
assert task.complete


class _NullTask(_PlaceholderTask):
"""
Null task. For special purposes.
This task is always complete immediately after initialization.
"""
def __init__(self) -> None:
super().__init__(title='<null>', prefinish=True)

def __repr__(self) -> str:
return f'<_NullTask>'

_NULL_TASK = _NullTask()
33 changes: 32 additions & 1 deletion src/crystal/tests/util/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import annotations

from contextlib import contextmanager
from crystal.model import Project
import crystal.task
from crystal.task import Task
from crystal.tests.util.controls import TreeItem
from crystal.tests.util.runner import bg_sleep
from crystal.tests.util.wait import (
Expand All @@ -9,7 +12,8 @@
)
import math
import re
from typing import Callable, List, Optional
from typing import Callable, List, Iterator, Optional
from unittest.mock import patch
import wx


Expand Down Expand Up @@ -119,4 +123,31 @@ def first_task_title():
return first_task_title


# ------------------------------------------------------------------------------
# Utility: Scheduler Manual Control

scheduler_disabled = patch('crystal.task.start_schedule_forever', lambda task: None)


def mark_as_complete(task: Task) -> None:
assert not task.complete
task.finish()
assert task.complete


@contextmanager
def clear_top_level_tasks_on_exit(project: Project) -> Iterator[None]:
try:
yield
finally:
# Force root task children to complete
for c in project.root_task.children:
if not c.complete:
mark_as_complete(c)

# Clear root task children
assert None == project.root_task.try_get_next_task_unit()
assert len(project.root_task.children) == 0


# ------------------------------------------------------------------------------

0 comments on commit d35d80f

Please sign in to comment.