Skip to content

Commit 2fae8e3

Browse files
committed
refactor(langserver): implement cancelable threads and remove async code from definition, implementations, hover, folding
1 parent 4b3e65c commit 2fae8e3

File tree

18 files changed

+240
-195
lines changed

18 files changed

+240
-195
lines changed
Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import inspect
2-
from typing import Any, Callable, TypeVar
2+
from concurrent.futures import CancelledError, Future
3+
from threading import Thread, current_thread, local
4+
from typing import Any, Callable, Dict, Generic, Optional, Tuple, TypeVar, cast
35

46
_F = TypeVar("_F", bound=Callable[..., Any])
7+
_TResult = TypeVar("_TResult")
58

69
__THREADED_MARKER = "__threaded__"
710

811

12+
class FutureEx(Future, Generic[_TResult]): # type: ignore[type-arg]
13+
def __init__(self) -> None:
14+
super().__init__()
15+
self.cancelation_requested = False
16+
17+
def cancel(self) -> bool:
18+
self.cancelation_requested = True
19+
return super().cancel()
20+
21+
def result(self, timeout: Optional[float] = None) -> _TResult:
22+
return cast(_TResult, super().result(timeout))
23+
24+
925
def threaded(enabled: bool = True) -> Callable[[_F], _F]:
1026
def decorator(func: _F) -> _F:
1127
setattr(func, __THREADED_MARKER, enabled)
@@ -14,5 +30,51 @@ def decorator(func: _F) -> _F:
1430
return decorator
1531

1632

17-
def is_threaded_callable(func: Callable[..., Any]) -> bool:
18-
return getattr(func, __THREADED_MARKER, False) or inspect.ismethod(func) and getattr(func, __THREADED_MARKER, False)
33+
def is_threaded_callable(callable: Callable[..., Any]) -> bool:
34+
return (
35+
getattr(callable, __THREADED_MARKER, False)
36+
or inspect.ismethod(callable)
37+
and getattr(callable, __THREADED_MARKER, False)
38+
)
39+
40+
41+
class _Local(local):
42+
def __init__(self) -> None:
43+
super().__init__()
44+
self._local_future: Optional[FutureEx[Any]] = None
45+
46+
47+
_local_storage = _Local()
48+
49+
50+
def _run_callable_in_thread_handler(
51+
future: FutureEx[_TResult], callable: Callable[..., _TResult], args: Tuple[Any, ...], kwargs: Dict[str, Any]
52+
) -> None:
53+
_local_storage._local_future = future
54+
future.set_running_or_notify_cancel()
55+
try:
56+
future.set_result(callable(*args, **kwargs))
57+
except Exception as e:
58+
# TODO: add traceback to exception e.traceback = format_exc()
59+
60+
future.set_exception(e)
61+
finally:
62+
_local_storage._local_future = None
63+
64+
65+
def is_thread_cancelled() -> bool:
66+
return _local_storage._local_future is not None and _local_storage._local_future.cancelation_requested
67+
68+
69+
def check_thread_canceled() -> None:
70+
if _local_storage._local_future is not None and _local_storage._local_future.cancelation_requested:
71+
name = current_thread().name
72+
raise CancelledError(f"Thread {name+' ' if name else ' '}Cancelled")
73+
74+
75+
def run_callable_in_thread(callable: Callable[..., _TResult], *args: Any, **kwargs: Any) -> FutureEx[_TResult]:
76+
future: FutureEx[_TResult] = FutureEx()
77+
78+
Thread(target=_run_callable_in_thread_handler, args=(future, callable, args, kwargs), name=str(callable)).start()
79+
80+
return future

packages/jsonrpc2/src/robotcode/jsonrpc2/protocol.py

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import asyncio
44
import concurrent.futures
5+
import functools
56
import inspect
67
import json
78
import re
89
import threading
9-
import time
1010
import weakref
1111
from abc import ABC, abstractmethod
1212
from collections import OrderedDict
@@ -21,7 +21,6 @@
2121
Iterator,
2222
List,
2323
Mapping,
24-
NamedTuple,
2524
Optional,
2625
Protocol,
2726
Set,
@@ -42,7 +41,7 @@
4241
from robotcode.core.utils.dataclasses import as_json, from_dict
4342
from robotcode.core.utils.inspect import ensure_coroutine, iter_methods
4443
from robotcode.core.utils.logging import LoggingDescriptor
45-
from robotcode.core.utils.threading import is_threaded_callable
44+
from robotcode.core.utils.threading import is_threaded_callable, run_callable_in_thread
4645

4746
__all__ = [
4847
"JsonRPCErrors",
@@ -344,15 +343,23 @@ def get_param_type(self, name: str) -> Optional[Type[Any]]:
344343
return result.param_type
345344

346345

347-
class SendedRequestEntry(NamedTuple):
348-
future: concurrent.futures.Future[Any]
349-
result_type: Optional[Type[Any]]
346+
class SendedRequestEntry:
347+
def __init__(self, future: concurrent.futures.Future[Any], result_type: Optional[Type[Any]]) -> None:
348+
self.future = future
349+
self.result_type = result_type
350350

351351

352-
class ReceivedRequestEntry(NamedTuple):
353-
future: asyncio.Future[Any]
354-
request: Optional[Any]
355-
cancelable: bool
352+
class ReceivedRequestEntry:
353+
def __init__(self, future: asyncio.Future[Any], request: JsonRPCRequest, cancelable: bool) -> None:
354+
self.future = future
355+
self.request = request
356+
self.cancelable = cancelable
357+
self.cancel_requested = False
358+
359+
def cancel(self) -> None:
360+
self.cancel_requested = True
361+
if self.future is not None and not self.future.cancelled():
362+
self.future.cancel()
356363

357364

358365
class JsonRPCProtocolBase(asyncio.Protocol, ABC):
@@ -711,7 +718,6 @@ def _convert_params(
711718
return args, kw_args
712719

713720
async def handle_request(self, message: JsonRPCRequest) -> None:
714-
start = time.monotonic_ns()
715721
try:
716722
e = self.registry.get_entry(message.method)
717723

@@ -725,13 +731,18 @@ async def handle_request(self, message: JsonRPCRequest) -> None:
725731

726732
params = self._convert_params(e.method, e.param_type, message.params)
727733

728-
if not e.is_coroutine:
734+
is_threaded_method = is_threaded_callable(e.method)
735+
736+
if not is_threaded_method and not e.is_coroutine:
729737
self.send_response(message.id, e.method(*params[0], **params[1]))
730738
else:
731-
if is_threaded_callable(e.method):
732-
task = run_coroutine_in_thread(
733-
ensure_coroutine(cast(Callable[..., Any], e.method)), *params[0], **params[1]
734-
)
739+
if is_threaded_method:
740+
if e.is_coroutine:
741+
task = run_coroutine_in_thread(
742+
ensure_coroutine(cast(Callable[..., Any], e.method)), *params[0], **params[1]
743+
)
744+
else:
745+
task = asyncio.wrap_future(run_callable_in_thread(e.method, *params[0], **params[1]))
735746
else:
736747
task = create_sub_task(
737748
ensure_coroutine(e.method)(*params[0], **params[1]),
@@ -741,49 +752,56 @@ async def handle_request(self, message: JsonRPCRequest) -> None:
741752
with self._received_request_lock:
742753
self._received_request[message.id] = ReceivedRequestEntry(task, message, e.cancelable)
743754

744-
def done(t: asyncio.Future[Any]) -> None:
745-
try:
746-
if not t.cancelled():
747-
ex = t.exception()
748-
if ex is not None:
749-
self.__logger.exception(ex, exc_info=ex)
750-
raise JsonRPCErrorException(
751-
JsonRPCErrors.INTERNAL_ERROR, f"{type(ex).__name__}: {ex}"
752-
) from ex
753-
754-
self.send_response(message.id, t.result())
755-
except asyncio.CancelledError:
756-
self.__logger.debug(lambda: f"request message {message!r} canceled")
757-
self.send_error(JsonRPCErrors.REQUEST_CANCELLED, "Request canceled.", id=message.id)
758-
except (SystemExit, KeyboardInterrupt):
759-
raise
760-
except JsonRPCErrorException as e:
761-
self.send_error(e.code, e.message or f"{type(e).__name__}: {e}", id=message.id, data=e.data)
762-
except BaseException as e:
763-
self.__logger.exception(e)
764-
self.send_error(JsonRPCErrors.INTERNAL_ERROR, f"{type(e).__name__}: {e}", id=message.id)
765-
finally:
766-
with self._received_request_lock:
767-
self._received_request.pop(message.id, None)
768-
769-
task.add_done_callback(done)
755+
task.add_done_callback(functools.partial(self._received_request_done, message))
770756

771757
await task
772-
finally:
773-
self.__logger.debug(lambda: f"request message {message!r} done in {time.monotonic_ns() - start}ns")
758+
except (SystemExit, KeyboardInterrupt, asyncio.CancelledError):
759+
raise
760+
except BaseException as e:
761+
self.__logger.exception(e)
762+
763+
def _received_request_done(self, message: JsonRPCRequest, t: asyncio.Future[Any]) -> None:
764+
try:
765+
with self._received_request_lock:
766+
entry = self._received_request.pop(message.id, None)
767+
768+
if entry is None:
769+
self.__logger.critical(lambda: f"unknown request {message!r}")
770+
return
771+
772+
if entry.cancel_requested:
773+
self.__logger.debug(lambda: f"request {message!r} canceled")
774+
self.send_error(JsonRPCErrors.REQUEST_CANCELLED, "Request canceled.", id=message.id)
775+
else:
776+
if not t.cancelled():
777+
ex = t.exception()
778+
if ex is not None:
779+
self.__logger.exception(ex, exc_info=ex)
780+
raise JsonRPCErrorException(JsonRPCErrors.INTERNAL_ERROR, f"{type(ex).__name__}: {ex}") from ex
781+
782+
self.send_response(message.id, t.result())
783+
except asyncio.CancelledError:
784+
self.__logger.debug(lambda: f"request message {message!r} canceled")
785+
self.send_error(JsonRPCErrors.REQUEST_CANCELLED, "Request canceled.", id=message.id)
786+
except (SystemExit, KeyboardInterrupt):
787+
raise
788+
except JsonRPCErrorException as e:
789+
self.send_error(e.code, e.message or f"{type(e).__name__}: {e}", id=message.id, data=e.data)
790+
except BaseException as e:
791+
self.__logger.exception(e)
792+
self.send_error(JsonRPCErrors.INTERNAL_ERROR, f"{type(e).__name__}: {e}", id=message.id)
774793

775794
def cancel_request(self, id: Union[int, str, None]) -> None:
776795
with self._received_request_lock:
777796
entry = self._received_request.get(id, None)
778797

779-
if entry is not None and entry.future is not None and not entry.future.cancelled():
798+
if entry is not None:
780799
self.__logger.debug(lambda: f"try to cancel request {entry.request if entry is not None else ''}")
781-
entry.future.cancel()
800+
entry.cancel()
782801

783802
def cancel_all_received_request(self) -> None:
784803
for entry in self._received_request.values():
785-
if entry is not None and entry.cancelable and entry.future is not None and not entry.future.cancelled():
786-
entry.future.cancel()
804+
entry.cancel()
787805

788806
@__logger.call
789807
async def handle_notification(self, message: JsonRPCNotification) -> None:

packages/language_server/src/robotcode/language_server/common/parts/definition.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

3-
from asyncio import CancelledError
3+
from concurrent.futures import CancelledError
44
from typing import TYPE_CHECKING, Any, Final, List, Optional, Union
55

6-
from robotcode.core.async_tools import async_tasking_event
6+
from robotcode.core.event import event
77
from robotcode.core.lsp.types import (
88
DefinitionParams,
99
Location,
@@ -13,7 +13,7 @@
1313
TextDocumentIdentifier,
1414
)
1515
from robotcode.core.utils.logging import LoggingDescriptor
16-
from robotcode.core.utils.threading import threaded
16+
from robotcode.core.utils.threading import check_thread_canceled, threaded
1717
from robotcode.jsonrpc2.protocol import rpc_method
1818
from robotcode.language_server.common.decorators import language_id_filter
1919
from robotcode.language_server.common.has_extend_capabilities import HasExtendCapabilities
@@ -31,8 +31,8 @@ def __init__(self, parent: LanguageServerProtocol) -> None:
3131
super().__init__(parent)
3232
self.link_support = False
3333

34-
@async_tasking_event
35-
async def collect(
34+
@event
35+
def collect(
3636
sender, document: TextDocument, position: Position # NOSONAR
3737
) -> Union[Location, List[Location], List[LocationLink], None]:
3838
...
@@ -50,7 +50,7 @@ def extend_capabilities(self, capabilities: ServerCapabilities) -> None:
5050

5151
@rpc_method(name="textDocument/definition", param_type=DefinitionParams)
5252
@threaded()
53-
async def _text_document_definition(
53+
def _text_document_definition(
5454
self, text_document: TextDocumentIdentifier, position: Position, *args: Any, **kwargs: Any
5555
) -> Optional[Union[Location, List[Location], List[LocationLink]]]:
5656
locations: List[Location] = []
@@ -60,9 +60,11 @@ async def _text_document_definition(
6060
if document is None:
6161
return None
6262

63-
for result in await self.collect(
63+
for result in self.collect(
6464
self, document, document.position_from_utf16(position), callback_filter=language_id_filter(document)
6565
):
66+
check_thread_canceled()
67+
6668
if isinstance(result, BaseException):
6769
if not isinstance(result, CancelledError):
6870
self._logger.exception(result, exc_info=result)

packages/language_server/src/robotcode/language_server/common/parts/folding_range.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from __future__ import annotations
2-
3-
from asyncio import CancelledError
1+
from concurrent.futures import CancelledError
42
from typing import TYPE_CHECKING, Any, Final, List, Optional
53

6-
from robotcode.core.async_tools import async_tasking_event
4+
from robotcode.core.event import event
75
from robotcode.core.lsp.types import (
86
FoldingRange,
97
FoldingRangeParams,
@@ -26,11 +24,11 @@
2624
class FoldingRangeProtocolPart(LanguageServerProtocolPart, HasExtendCapabilities):
2725
_logger: Final = LoggingDescriptor()
2826

29-
def __init__(self, parent: LanguageServerProtocol) -> None:
27+
def __init__(self, parent: "LanguageServerProtocol") -> None:
3028
super().__init__(parent)
3129

32-
@async_tasking_event
33-
async def collect(sender, document: TextDocument) -> Optional[List[FoldingRange]]: # pragma: no cover, NOSONAR
30+
@event
31+
def collect(sender, document: TextDocument) -> Optional[List[FoldingRange]]: # pragma: no cover, NOSONAR
3432
...
3533

3634
def extend_capabilities(self, capabilities: ServerCapabilities) -> None:
@@ -39,15 +37,15 @@ def extend_capabilities(self, capabilities: ServerCapabilities) -> None:
3937

4038
@rpc_method(name="textDocument/foldingRange", param_type=FoldingRangeParams)
4139
@threaded()
42-
async def _text_document_folding_range(
40+
def _text_document_folding_range(
4341
self, text_document: TextDocumentIdentifier, *args: Any, **kwargs: Any
4442
) -> Optional[List[FoldingRange]]:
4543
results: List[FoldingRange] = []
4644
document = self.parent.documents.get(text_document.uri)
4745
if document is None:
4846
return None
4947

50-
for result in await self.collect(self, document, callback_filter=language_id_filter(document)):
48+
for result in self.collect(self, document, callback_filter=language_id_filter(document)):
5149
if isinstance(result, BaseException):
5250
if not isinstance(result, CancelledError):
5351
self._logger.exception(result, exc_info=result)
@@ -58,14 +56,10 @@ async def _text_document_folding_range(
5856
if not results:
5957
return None
6058

61-
for result in results:
62-
if result.start_character is not None:
63-
result.start_character = document.position_to_utf16(
64-
Position(result.start_line, result.start_character)
65-
).character
66-
if result.end_character is not None:
67-
result.end_character = document.position_to_utf16(
68-
Position(result.end_line, result.end_character)
69-
).character
59+
for r in results:
60+
if r.start_character is not None:
61+
r.start_character = document.position_to_utf16(Position(r.start_line, r.start_character)).character
62+
if r.end_character is not None:
63+
r.end_character = document.position_to_utf16(Position(r.end_line, r.end_character)).character
7064

7165
return results

0 commit comments

Comments
 (0)