Skip to content

Commit 1b1d6cd

Browse files
committed
fix: do not leak resources across tests so as to prevent side effects
Problem: Across unit tests, custom path_hook reigstered globally by ScriptHost globally was not being cleared up properly. This results in leakage of internal resources and dangling access to them (such as asyncio event loops that was already closed and no longer valid in other test case instances). More specifically, the asyncio EventLoop were never closed, which can result in "Event loop is closed" errors upon garbage collection of internal transport objects (during cleaning up pytest sessions). Solution: (1) Always call ScriptHost.teardown() when done using it. (2) Make sure all internal resources (event loops, transport channels, etc.) are closed by closing the embedded neovim instance.
1 parent 964e6b1 commit 1b1d6cd

File tree

3 files changed

+38
-10
lines changed

3 files changed

+38
-10
lines changed

pynvim/msgpack_rpc/event_loop/asyncio.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import sys
1515
from collections import deque
1616
from signal import Signals
17-
from typing import Any, Callable, Deque, List
17+
from typing import Any, Callable, Deque, List, Optional
1818

1919
from pynvim.msgpack_rpc.event_loop.base import BaseEventLoop
2020

@@ -37,6 +37,7 @@ class AsyncioEventLoop(BaseEventLoop, asyncio.Protocol,
3737
"""`BaseEventLoop` subclass that uses `asyncio` as a backend."""
3838

3939
_queued_data: Deque[bytes]
40+
_child_watcher: Optional[asyncio.AbstractChildWatcher]
4041

4142
def connection_made(self, transport):
4243
"""Used to signal `asyncio.Protocol` of a successful connection."""
@@ -78,6 +79,7 @@ def _init(self) -> None:
7879
self._queued_data = deque()
7980
self._fact = lambda: self
8081
self._raw_transport = None
82+
self._child_watcher = None
8183

8284
def _connect_tcp(self, address: str, port: int) -> None:
8385
coroutine = self._loop.create_connection(self._fact, address, port)
@@ -145,6 +147,9 @@ def _close(self) -> None:
145147
if self._raw_transport is not None:
146148
self._raw_transport.close()
147149
self._loop.close()
150+
if self._child_watcher is not None:
151+
self._child_watcher.close()
152+
self._child_watcher = None
148153

149154
def _threadsafe_call(self, fn: Callable[[], Any]) -> None:
150155
self._loop.call_soon_threadsafe(fn)

test/conftest.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
"""Configs for pytest."""
2+
3+
import gc
14
import json
25
import os
6+
from typing import Generator
37

48
import pytest
59

@@ -9,7 +13,10 @@
913

1014

1115
@pytest.fixture
12-
def vim() -> pynvim.Nvim:
16+
def vim() -> Generator[pynvim.Nvim, None, None]:
17+
"""Create an embedded, sub-process Nvim fixture instance."""
18+
editor: pynvim.Nvim
19+
1320
child_argv = os.environ.get('NVIM_CHILD_ARGV')
1421
listen_address = os.environ.get('NVIM_LISTEN_ADDRESS')
1522
if child_argv is None and listen_address is None:
@@ -28,4 +35,13 @@ def vim() -> pynvim.Nvim:
2835
assert listen_address is not None and listen_address != ''
2936
editor = pynvim.attach('socket', path=listen_address)
3037

31-
return editor
38+
try:
39+
yield editor
40+
41+
finally:
42+
# Ensure all internal resources (pipes, transports, etc.) are always
43+
# closed properly. Otherwise, during GC finalizers (__del__) will raise
44+
# "Event loop is closed" error.
45+
editor.close()
46+
47+
gc.collect() # force-run GC, to early-detect potential leakages

test/test_host.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@
1111

1212
def test_host_imports(vim):
1313
h = ScriptHost(vim)
14-
assert h.module.__dict__['vim']
15-
assert h.module.__dict__['vim'] == h.legacy_vim
16-
assert h.module.__dict__['sys']
14+
try:
15+
assert h.module.__dict__['vim']
16+
assert h.module.__dict__['vim'] == h.legacy_vim
17+
assert h.module.__dict__['sys']
18+
finally:
19+
h.teardown()
1720

1821

1922
def test_host_import_rplugin_modules(vim):
2023
# Test whether a Host can load and import rplugins (#461).
2124
# See also $VIMRUNTIME/autoload/provider/pythonx.vim.
2225
h = Host(vim)
26+
2327
plugins: Sequence[str] = [ # plugin paths like real rplugins
2428
os.path.join(__PATH__, "./fixtures/simple_plugin/rplugin/python3/simple_nvim.py"),
2529
os.path.join(__PATH__, "./fixtures/module_plugin/rplugin/python3/mymodule/"),
@@ -56,7 +60,10 @@ def test_host_async_error(vim):
5660

5761
def test_legacy_vim_eval(vim):
5862
h = ScriptHost(vim)
59-
assert h.legacy_vim.eval('1') == '1'
60-
assert h.legacy_vim.eval('v:null') is None
61-
assert h.legacy_vim.eval('v:true') is True
62-
assert h.legacy_vim.eval('v:false') is False
63+
try:
64+
assert h.legacy_vim.eval('1') == '1'
65+
assert h.legacy_vim.eval('v:null') is None
66+
assert h.legacy_vim.eval('v:true') is True
67+
assert h.legacy_vim.eval('v:false') is False
68+
finally:
69+
h.teardown()

0 commit comments

Comments
 (0)