Skip to content

Commit 5cb7bb7

Browse files
committed
refactor(jsonrpc2): harden resource lifecycle without __del__
- remove __del__-based resource cleanup from server, debugger client, and protocol - move shutdown to explicit close/close_async paths - make JsonRPCServer.close wait for server shutdown when safe - add best-effort weakref finalizers for forgotten cleanup without noisy warnings - unify runtime and finalizer bookkeeping into shared state objects
1 parent 4e66eaa commit 5cb7bb7

4 files changed

Lines changed: 179 additions & 21 deletions

File tree

packages/debugger/src/robotcode/debugger/launcher/client.py

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

33
import asyncio
4+
import logging
5+
import weakref
46
from typing import Any, Optional, Sequence
57

68
from robotcode.core.event import event
@@ -38,6 +40,17 @@ class DAPClientError(Exception):
3840
pass
3941

4042

43+
class _DAPClientState:
44+
__slots__ = ("closed", "logger", "loop", "protocol", "transport")
45+
46+
def __init__(self, logger: logging.Logger) -> None:
47+
self.logger = logger
48+
self.loop: Optional[asyncio.AbstractEventLoop] = None
49+
self.protocol: Optional[DAPClientProtocol] = None
50+
self.transport: Optional[asyncio.BaseTransport] = None
51+
self.closed = False
52+
53+
4154
class DAPClient:
4255
_logger = LoggingDescriptor()
4356

@@ -48,8 +61,43 @@ def __init__(
4861
) -> None:
4962
self.parent = parent
5063
self.tcp_params = tcp_params
51-
self._protocol: Optional[DAPClientProtocol] = None
52-
self._transport: Optional[asyncio.BaseTransport] = None
64+
self._state = _DAPClientState(
65+
logging.getLogger(f"{type(self).__module__}.{type(self).__qualname__}"),
66+
)
67+
self._finalizer = weakref.finalize(self, DAPClient._finalize_resources, self._state)
68+
69+
@staticmethod
70+
def _finalize_resources(state: _DAPClientState) -> None:
71+
if state.closed or state.transport is None:
72+
return
73+
74+
try:
75+
if state.loop is not None and not state.loop.is_closed() and state.loop.is_running():
76+
state.loop.call_soon_threadsafe(state.transport.close)
77+
else:
78+
state.transport.close()
79+
except BaseException:
80+
pass
81+
82+
state.logger.debug(
83+
"DAPClient was garbage collected without calling close(); the transport was closed best-effort only.",
84+
)
85+
86+
@property
87+
def _protocol(self) -> Optional[DAPClientProtocol]:
88+
return self._state.protocol
89+
90+
@_protocol.setter
91+
def _protocol(self, value: Optional[DAPClientProtocol]) -> None:
92+
self._state.protocol = value
93+
94+
@property
95+
def _transport(self) -> Optional[asyncio.BaseTransport]:
96+
return self._state.transport
97+
98+
@_transport.setter
99+
def _transport(self, value: Optional[asyncio.BaseTransport]) -> None:
100+
self._state.transport = value
53101

54102
@event
55103
def on_closed(sender) -> None: ...
@@ -61,11 +109,10 @@ def close(self) -> None:
61109
self._transport = None
62110
self._protocol = None
63111

112+
self._state.closed = True
113+
self._finalizer.detach()
64114
self.on_closed(self)
65115

66-
def __del__(self) -> None:
67-
self.close()
68-
69116
@_logger.call
70117
def on_connection_lost(self, sender: Any, exc: Optional[BaseException]) -> None:
71118
if sender == self._protocol:
@@ -76,6 +123,8 @@ async def connect(self, timeout: float = 5) -> DAPClientProtocol:
76123
async def wait() -> None:
77124
while self._protocol is None:
78125
try:
126+
current_loop = asyncio.get_running_loop()
127+
self._state.loop = current_loop
79128
if self.tcp_params.host is not None:
80129
if isinstance(self.tcp_params.host, Sequence):
81130
host = self.tcp_params.host[0]
@@ -86,7 +135,7 @@ async def wait() -> None:
86135
(
87136
self._transport,
88137
protocol,
89-
) = await asyncio.get_running_loop().create_connection(
138+
) = await current_loop.create_connection(
90139
self._create_protocol,
91140
host=host,
92141
port=self.tcp_params.port,

packages/debugger/src/robotcode/debugger/launcher/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ def client(self, value: DAPClient) -> None:
8585
def connected(self) -> bool:
8686
return self._client is not None and self._client.connected
8787

88+
def close(self) -> None:
89+
if self._client is not None:
90+
self._client.close()
91+
self._client = None
92+
8893
@rpc_method(name="initialize", param_type=InitializeRequestArguments)
8994
async def _initialize(self, arguments: InitializeRequestArguments, *args: Any, **kwargs: Any) -> Capabilities:
9095
self._initialize_arguments = arguments
@@ -352,3 +357,9 @@ def create_protocol(self) -> LauncherDebugAdapterProtocol:
352357
self.protocol = LauncherDebugAdapterProtocol(debugger_script=self.debugger_script)
353358

354359
return self.protocol
360+
361+
def _close(self) -> None:
362+
if self.protocol is not None:
363+
self.protocol.close()
364+
365+
super()._close()

packages/jsonrpc2/src/robotcode/jsonrpc2/server.py

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import abc
22
import asyncio
33
import io
4+
import logging
45
import sys
56
import threading
7+
import weakref
68
from concurrent.futures import ThreadPoolExecutor
79
from types import TracebackType
810
from typing import (
@@ -50,6 +52,18 @@ def write(self, data: "bytes | bytearray | memoryview[Any]") -> None:
5052
self.wfile.flush()
5153

5254

55+
class _JsonRPCServerState:
56+
__slots__ = ("closed", "in_closing", "logger", "loop", "server", "stdio_stop_event")
57+
58+
def __init__(self, loop: asyncio.AbstractEventLoop, logger: logging.Logger) -> None:
59+
self.logger = logger
60+
self.loop = loop
61+
self.server: Optional[asyncio.AbstractServer] = None
62+
self.stdio_stop_event: Optional[threading.Event] = None
63+
self.in_closing = False
64+
self.closed = False
65+
66+
5367
class JsonRPCServer(Generic[TProtocol], abc.ABC):
5468
_logger = LoggingDescriptor()
5569

@@ -65,24 +79,78 @@ def __init__(
6579

6680
self._run_func: Optional[Callable[[], None]] = None
6781
self._serve_func: Optional[Callable[[], Coroutine[None, None, None]]] = None
68-
self._server: Optional[asyncio.AbstractServer] = None
69-
70-
self._stdio_stop_event: Optional[threading.Event] = None
71-
72-
self._in_closing = False
73-
self._closed = False
7482

7583
try:
76-
self.loop = asyncio.get_event_loop()
84+
loop = asyncio.get_event_loop()
7785
except RuntimeError:
78-
self.loop = asyncio.new_event_loop()
79-
asyncio.set_event_loop(self.loop)
86+
loop = asyncio.new_event_loop()
87+
asyncio.set_event_loop(loop)
88+
89+
self._state = _JsonRPCServerState(
90+
loop,
91+
logging.getLogger(f"{type(self).__module__}.{type(self).__qualname__}"),
92+
)
93+
self._finalizer = weakref.finalize(self, JsonRPCServer._finalize_resources, self._state)
8094

8195
if self.loop is not None:
8296
self.loop.slow_callback_duration = 10
8397

84-
def __del__(self) -> None:
85-
self.close()
98+
@staticmethod
99+
def _finalize_resources(state: _JsonRPCServerState) -> None:
100+
if state.closed:
101+
return
102+
103+
if state.stdio_stop_event is not None:
104+
state.stdio_stop_event.set()
105+
106+
if state.server is not None:
107+
try:
108+
if not state.loop.is_closed() and state.loop.is_running():
109+
state.loop.call_soon_threadsafe(state.server.close)
110+
else:
111+
state.server.close()
112+
except BaseException:
113+
pass
114+
115+
state.logger.debug(
116+
"JsonRPCServer was garbage collected without calling close(); resources were cleaned up best-effort only.",
117+
)
118+
119+
@property
120+
def loop(self) -> asyncio.AbstractEventLoop:
121+
return self._state.loop
122+
123+
@property
124+
def _server(self) -> Optional[asyncio.AbstractServer]:
125+
return self._state.server
126+
127+
@_server.setter
128+
def _server(self, value: Optional[asyncio.AbstractServer]) -> None:
129+
self._state.server = value
130+
131+
@property
132+
def _stdio_stop_event(self) -> Optional[threading.Event]:
133+
return self._state.stdio_stop_event
134+
135+
@_stdio_stop_event.setter
136+
def _stdio_stop_event(self, value: Optional[threading.Event]) -> None:
137+
self._state.stdio_stop_event = value
138+
139+
@property
140+
def _in_closing(self) -> bool:
141+
return self._state.in_closing
142+
143+
@_in_closing.setter
144+
def _in_closing(self, value: bool) -> None:
145+
self._state.in_closing = value
146+
147+
@property
148+
def _closed(self) -> bool:
149+
return self._state.closed
150+
151+
@_closed.setter
152+
def _closed(self, value: bool) -> None:
153+
self._state.closed = value
86154

87155
@_logger.call
88156
def start(self) -> None:
@@ -122,17 +190,49 @@ def _close(self) -> None:
122190
if self._server and self._server.is_serving():
123191
self._server.close()
124192

193+
def _close_on_loop_thread(self) -> None:
194+
if self.loop.is_running() and getattr(self.loop, "_thread_id", None) != threading.get_ident():
195+
closed_event = threading.Event()
196+
197+
def close_on_loop() -> None:
198+
try:
199+
self._close()
200+
finally:
201+
closed_event.set()
202+
203+
self.loop.call_soon_threadsafe(close_on_loop)
204+
closed_event.wait()
205+
return
206+
207+
self._close()
208+
209+
def _wait_closed_sync(self) -> None:
210+
if self._server is None or self.loop.is_closed():
211+
return
212+
213+
if self.loop.is_running():
214+
if getattr(self.loop, "_thread_id", None) == threading.get_ident():
215+
asyncio.create_task(self._server.wait_closed(), name=f"{type(self).__name__}.wait_closed")
216+
return
217+
218+
asyncio.run_coroutine_threadsafe(self._server.wait_closed(), self.loop).result()
219+
return
220+
221+
self.loop.run_until_complete(self._server.wait_closed())
222+
125223
@_logger.call
126224
def close(self) -> None:
127225
if self._in_closing or self._closed:
128226
return
129227

130228
self._in_closing = True
131229
try:
132-
self._close()
230+
self._close_on_loop_thread()
231+
self._wait_closed_sync()
133232
finally:
134233
self._in_closing = False
135234
self._closed = True
235+
self._finalizer.detach()
136236

137237
@_logger.call
138238
async def close_async(self) -> None:
@@ -147,6 +247,7 @@ async def close_async(self) -> None:
147247
finally:
148248
self._in_closing = False
149249
self._closed = True
250+
self._finalizer.detach()
150251

151252
async def __aenter__(self) -> Self:
152253
await self.start_async()

packages/language_server/src/robotcode/language_server/common/protocol.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,6 @@ def __init__(self, server: JsonRPCServer[Any]):
161161

162162
self.is_initialized = Event()
163163

164-
def __del__(self) -> None:
165-
self.trace = TraceValues.OFF
166-
167164
@event
168165
def on_shutdown(sender) -> None: # pragma: no cover, NOSONAR
169166
...

0 commit comments

Comments
 (0)