Skip to content

Commit e0f1ec9

Browse files
authored
♻️Maintenance: improve cancellation error handling (#8367)
1 parent 87820ae commit e0f1ec9

File tree

17 files changed

+93
-71
lines changed

17 files changed

+93
-71
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
applyTo: '**'
3+
---
4+
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.
5+
6+
## General Guidelines
7+
8+
1. **Test-Driven Development**: Write unit tests for all new functions and features. Use `pytest` for Python and appropriate testing frameworks for Node.js.
9+
2. **Environment Variables**: Use [Environment Variables Guide](../../docs/env-vars.md) for configuration. Avoid hardcoding sensitive information.
10+
3. **Documentation**: Prefer self-explanatory code; add documentation only if explicitly requested by the developer. Be concise.
11+
4. **Code Reviews**: Participate in code reviews and provide constructive feedback.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
applyTo: '**/*.js'
3+
---
4+
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.
5+
6+
## 🛠️Coding Instructions for Node.js in This Repository
7+
8+
* Use ES6+ syntax and features.
9+
* Follow the `package.json` configuration for dependencies and scripts.
10+
* Use `eslint` for linting and `prettier` for code formatting.
11+
* Write modular and reusable code, adhering to the project's structure.

.github/copilot-instructions.md renamed to .github/instructions/python.instructions.md

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
# GitHub Copilot Instructions
2-
3-
This document provides guidelines and best practices for using GitHub Copilot in the `osparc-simcore` repository and other Python and Node.js projects.
4-
5-
## General Guidelines
6-
7-
1. **Test-Driven Development**: Write unit tests for all new functions and features. Use `pytest` for Python and appropriate testing frameworks for Node.js.
8-
2. **Environment Variables**: Use [Environment Variables Guide](../docs/env-vars.md) for configuration. Avoid hardcoding sensitive information.
9-
3. **Documentation**: Prefer self-explanatory code; add documentation only if explicitly requested by the developer.
10-
111
---
2+
applyTo: '**/*.py'
3+
---
4+
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.
125

136
## 🛠️Coding Instructions for Python in This Repository
147

@@ -25,8 +18,8 @@ Follow these rules **strictly** when generating Python code:
2518

2619
### 3. **Code Style & Formatting**
2720

28-
* Follow [Python Coding Conventions](../docs/coding-conventions.md) **strictly**.
29-
* Format code with `black`.
21+
* Follow [Python Coding Conventions](../../docs/coding-conventions.md) **strictly**.
22+
* Format code with `black` and `ruff`.
3023
* Lint code with `ruff` and `pylint`.
3124

3225
### 4. **Library Compatibility**
@@ -51,11 +44,6 @@ Ensure compatibility with the following library versions:
5144
* Prefer `json_dumps` / `json_loads` from `common_library.json_serialization` instead of the built-in `json.dumps` / `json.loads`.
5245
* When using Pydantic models, prefer methods like `model.model_dump_json()` for serialization.
5346

54-
---
55-
56-
## 🛠️Coding Instructions for Node.js in This Repository
57-
58-
* Use ES6+ syntax and features.
59-
* Follow the `package.json` configuration for dependencies and scripts.
60-
* Use `eslint` for linting and `prettier` for code formatting.
61-
* Write modular and reusable code, adhering to the project's structure.
47+
### 7. **Running tests**
48+
* Use `--keep-docker-up` flag when testing to keep docker containers up between sessions.
49+
* Always activate the python virtual environment before running pytest.

packages/common-library/src/common_library/async_tools.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,32 +89,37 @@ async def cancel_wait_task(
8989
TimeoutError: raised if cannot cancel the task.
9090
CancelledError: raised ONLY if owner is being cancelled.
9191
"""
92+
if task.done():
93+
# nothing to do here
94+
return
9295

93-
cancelling = task.cancel()
94-
if not cancelling:
95-
return # task was alredy cancelled
96-
97-
assert task.cancelling() # nosec
98-
assert not task.cancelled() # nosec
99-
96+
# mark for cancellation
97+
task.cancel("cancel_wait_task was called to cancel this task")
10098
try:
101-
99+
_logger.debug("Cancelling task %s", task.get_name())
102100
await asyncio.shield(
103101
# NOTE shield ensures that cancellation of the caller function won't stop you
104102
# from observing the cancellation/finalization of task.
105103
asyncio.wait_for(task, timeout=max_delay)
106104
)
107-
108-
except asyncio.CancelledError:
109-
if not task.cancelled():
110-
# task owner function is being cancelled -> propagate cancellation
111-
raise
112-
113-
# else: task cancellation is complete, we can safely ignore it
114-
_logger.debug(
115-
"Task %s cancellation is complete",
105+
except TimeoutError:
106+
_logger.exception(
107+
"Timeout while cancelling task %s after %s seconds",
116108
task.get_name(),
109+
max_delay,
117110
)
111+
raise
112+
except asyncio.CancelledError:
113+
current_task = asyncio.current_task()
114+
assert current_task is not None # nosec
115+
if current_task.cancelling() > 0:
116+
# owner function is being cancelled -> propagate cancellation
117+
raise
118+
finally:
119+
if not task.done():
120+
_logger.error("Failed to cancel %s", task.get_name())
121+
else:
122+
_logger.debug("Task %s cancelled", task.get_name())
118123

119124

120125
def delayed_start(

packages/service-library/src/servicelib/async_utils.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import logging
33
from collections import deque
44
from collections.abc import Awaitable, Callable
5-
from contextlib import suppress
65
from dataclasses import dataclass
76
from functools import wraps
87
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
98

9+
from common_library.async_tools import cancel_wait_task
10+
1011
from . import tracing
1112
from .utils_profiling_middleware import dont_profile, is_profiling, profile_context
1213

@@ -54,9 +55,7 @@ async def _safe_cancel(context: Context) -> None:
5455
try:
5556
await context.in_queue.put(None)
5657
if context.task is not None:
57-
context.task.cancel()
58-
with suppress(asyncio.CancelledError):
59-
await context.task
58+
await cancel_wait_task(context.task, max_delay=None)
6059
except RuntimeError as e:
6160
if "Event loop is closed" in f"{e}":
6261
_logger.warning("event loop is closed and could not cancel %s", context)

packages/service-library/src/servicelib/deferred_tasks/_worker_tracker.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ async def handle_run(
5757
result_to_return = TaskResultSuccess(value=task_result)
5858
except asyncio.CancelledError:
5959
result_to_return = TaskResultCancelledError()
60+
# NOTE: if the task is itself cancelled it shall re-raise: see https://superfastpython.com/asyncio-cancellederror-consumed/
61+
current_task = asyncio.current_task()
62+
assert current_task is not None # nosec
63+
if current_task.cancelling() > 0:
64+
# owner function is being cancelled -> propagate cancellation
65+
raise
6066
except Exception as e: # pylint:disable=broad-exception-caught
6167
result_to_return = TaskResultError(
6268
error=_format_exception(e),

packages/service-library/src/servicelib/fastapi/requests_decorators.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from functools import wraps
55
from typing import Any, Protocol
66

7+
from common_library.async_tools import cancel_wait_task
78
from fastapi import Request, status
89
from fastapi.exceptions import HTTPException
910

@@ -13,8 +14,7 @@
1314
class _HandlerWithRequestArg(Protocol):
1415
__name__: str
1516

16-
async def __call__(self, request: Request, *args: Any, **kwargs: Any) -> Any:
17-
...
17+
async def __call__(self, request: Request, *args: Any, **kwargs: Any) -> Any: ...
1818

1919

2020
def _validate_signature(handler: _HandlerWithRequestArg):
@@ -75,13 +75,8 @@ async def wrapper(request: Request, *args, **kwargs):
7575

7676
# One has completed, cancel the other
7777
for t in pending:
78-
t.cancel()
79-
8078
try:
81-
await asyncio.wait_for(t, timeout=3)
82-
83-
except asyncio.CancelledError:
84-
pass
79+
await cancel_wait_task(t, max_delay=3)
8580
except Exception: # pylint: disable=broad-except
8681
if t is handler_task:
8782
raise

packages/service-library/src/servicelib/long_running_tasks/task.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ async def _tasks_monitor(self) -> None:
335335
result_field = ResultField(
336336
str_error=dumps(TaskCancelledError(task_id=task_id))
337337
)
338+
# NOTE: if the task is itself cancelled it shall re-raise: see https://superfastpython.com/asyncio-cancellederror-consumed/
339+
current_task = asyncio.current_task()
340+
assert current_task is not None # nosec
341+
if current_task.cancelling() > 0:
342+
# owner function is being cancelled -> propagate cancellation
343+
raise
338344
except Exception as e: # pylint:disable=broad-except
339345
allowed_errors = TaskRegistry.get_allowed_errors(
340346
task_data.registered_task_name

packages/service-library/src/servicelib/redis/_decorators.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import arrow
1010
import redis.exceptions
11+
from common_library.async_tools import cancel_wait_task
1112
from redis.asyncio.lock import Lock
1213

1314
from ..background_task import periodic
@@ -116,10 +117,12 @@ async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
116117
module_name=coro.__module__, func_name=coro.__name__
117118
),
118119
)
119-
120120
res = await work_task
121-
auto_extend_lock_task.cancel()
122-
return res
121+
# cancel the auto-extend task (work is done)
122+
# NOTE: if we do not explicitely await the task inside the context manager
123+
# it sometimes hangs forever (Python issue?)
124+
await cancel_wait_task(auto_extend_lock_task, max_delay=None)
125+
return res
123126

124127
except BaseExceptionGroup as eg:
125128
# Separate exceptions into LockLostError and others

packages/service-library/src/servicelib/redis/_utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from collections.abc import Awaitable
3-
from typing import Any
3+
from typing import ParamSpec, TypeVar
44

55
import redis.exceptions
66
from redis.asyncio.lock import Lock
@@ -28,7 +28,11 @@ async def auto_extend_lock(lock: Lock) -> None:
2828
raise LockLostError(lock=lock) from exc
2929

3030

31-
async def handle_redis_returns_union_types(result: Any | Awaitable[Any]) -> Any:
31+
P = ParamSpec("P")
32+
R = TypeVar("R")
33+
34+
35+
async def handle_redis_returns_union_types(result: R | Awaitable[R]) -> R:
3236
"""Used to handle mypy issues with redis 5.x return types"""
3337
if isinstance(result, Awaitable):
3438
return await result

0 commit comments

Comments
 (0)