Skip to content

Commit 55002fd

Browse files
committed
teardown tasks when the backend is deleted
1 parent 80db7e7 commit 55002fd

File tree

4 files changed

+92
-64
lines changed

4 files changed

+92
-64
lines changed

src/fastcs/backend.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import asyncio
22
from collections import defaultdict
33
from collections.abc import Callable
4-
from concurrent.futures import Future
54
from types import MethodType
65

76
from softioc.asyncio_dispatcher import AsyncioDispatcher
@@ -21,7 +20,7 @@ def __init__(
2120
self._controller = controller
2221

2322
self._initial_tasks = [controller.connect]
24-
self._scan_tasks: list[Future] = []
23+
self._scan_tasks: list[asyncio.Task] = []
2524

2625
asyncio.run_coroutine_threadsafe(
2726
self._controller.initialise(), self._loop
@@ -41,30 +40,31 @@ def _link_process_tasks(self):
4140
_link_single_controller_put_tasks(single_mapping)
4241
_link_attribute_sender_class(single_mapping)
4342

43+
def __del__(self):
44+
self.stop_scan_tasks()
45+
4446
def run(self):
4547
self._run_initial_tasks()
46-
self._start_scan_tasks()
47-
48+
self.start_scan_tasks()
4849
self._run()
4950

5051
def _run_initial_tasks(self):
5152
for task in self._initial_tasks:
5253
future = asyncio.run_coroutine_threadsafe(task(), self._loop)
5354
future.result()
5455

55-
def _start_scan_tasks(self):
56-
async def run_tasks():
57-
futures = [task() for task in _get_scan_tasks(self._mapping)]
58-
for future in asyncio.as_completed(futures):
59-
try:
60-
await future
61-
except Exception as e:
62-
# We don't exit the ioc when a scan loop errors,
63-
# but we do print the information.
64-
print(f"Scan loop stopped with exception:\n {e}")
65-
raise e
56+
def start_scan_tasks(self):
57+
self._scan_tasks = [
58+
self._loop.create_task(coro()) for coro in _get_scan_coros(self._mapping)
59+
]
6660

67-
asyncio.run_coroutine_threadsafe(run_tasks(), self._loop)
61+
def stop_scan_tasks(self):
62+
for task in self._scan_tasks:
63+
if not task.done():
64+
try:
65+
task.cancel()
66+
except asyncio.CancelledError:
67+
pass
6868

6969
def _run(self):
7070
raise NotImplementedError("Specific Backend must implement _run")
@@ -106,15 +106,15 @@ async def callback(value):
106106
return callback
107107

108108

109-
def _get_scan_tasks(mapping: Mapping) -> list[Callable]:
109+
def _get_scan_coros(mapping: Mapping) -> list[Callable]:
110110
scan_dict: dict[float, list[Callable]] = defaultdict(list)
111111

112112
for single_mapping in mapping.get_controller_mappings():
113113
_add_scan_method_tasks(scan_dict, single_mapping)
114114
_add_attribute_updater_tasks(scan_dict, single_mapping)
115115

116-
scan_tasks = _get_periodic_scan_tasks(scan_dict)
117-
return scan_tasks
116+
scan_coros = _get_periodic_scan_coros(scan_dict)
117+
return scan_coros
118118

119119

120120
def _add_scan_method_tasks(
@@ -152,18 +152,18 @@ async def callback():
152152
return callback
153153

154154

155-
def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Callable]:
156-
periodic_scan_tasks: list[Callable] = []
155+
def _get_periodic_scan_coros(scan_dict: dict[float, list[Callable]]) -> list[Callable]:
156+
periodic_scan_coros: list[Callable] = []
157157
for period, methods in scan_dict.items():
158-
periodic_scan_tasks.append(_create_periodic_scan_task(period, methods))
158+
periodic_scan_coros.append(_create_periodic_scan_coro(period, methods))
159159

160-
return periodic_scan_tasks
160+
return periodic_scan_coros
161161

162162

163-
def _create_periodic_scan_task(period, methods: list[Callable]) -> Callable:
164-
async def scan_task() -> None:
163+
def _create_periodic_scan_coro(period, methods: list[Callable]) -> Callable:
164+
async def scan_coro() -> None:
165165
while True:
166166
await asyncio.gather(*[method() for method in methods])
167167
await asyncio.sleep(period)
168168

169-
return scan_task
169+
return scan_coro

src/fastcs/backends/epics/ioc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ async def async_write_display(value: T):
226226
def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any:
227227
if attr_is_enum(attribute):
228228
assert attribute.allowed_values is not None and all(
229-
isinstance(v, str) for v in attribute.allowed_values
229+
isinstance(v, str) or isinstance(v, int) for v in attribute.allowed_values
230230
)
231231
state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False))
232232
return builder.mbbOut(pv, always_update=True, on_update=on_update, **state_keys)

tests/backends/epics/test_ioc.py

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@
77
from fastcs.backends.epics.ioc import (
88
EPICS_MAX_NAME_LENGTH,
99
EpicsIOC,
10-
_add_attr_pvi_info,
1110
_add_pvi_info,
12-
_add_sub_controller_pvi_info,
13-
_create_and_link_read_pv,
14-
_create_and_link_write_pv,
1511
_get_input_record,
1612
_get_output_record,
1713
)
@@ -27,17 +23,31 @@
2723
ONOFF_STATES = {"ZRST": "disabled", "ONST": "enabled"}
2824

2925

26+
@pytest.fixture
27+
def ioc_without_mapping(mocker: MockerFixture, mapping: Mapping):
28+
mocker.patch("fastcs.backends.epics.ioc.builder")
29+
mocker.patch("fastcs.backends.epics.ioc.EpicsIOC._create_and_link_attribute_pvs")
30+
mocker.patch("fastcs.backends.epics.ioc.EpicsIOC._create_and_link_command_pvs")
31+
32+
return EpicsIOC(DEVICE, mapping)
33+
34+
3035
@pytest.mark.asyncio
31-
async def test_create_and_link_read_pv(mocker: MockerFixture):
36+
async def test_create_and_link_read_pv(
37+
mocker: MockerFixture, ioc_without_mapping: EpicsIOC
38+
):
3239
get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record")
33-
add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info")
3440
attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum")
41+
mocker.patch("fastcs.backends.epics.ioc._add_pvi_info")
42+
add_attr_pvi_info = mocker.patch(
43+
"fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info"
44+
)
3545
record = get_input_record.return_value
3646

3747
attribute = mocker.MagicMock()
38-
3948
attr_is_enum.return_value = False
40-
_create_and_link_read_pv("PREFIX", "PV", "attr", attribute)
49+
50+
ioc_without_mapping._create_and_link_read_pv("PREFIX", "PV", "attr", attribute)
4151

4252
get_input_record.assert_called_once_with("PREFIX:PV", attribute)
4353
add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r")
@@ -51,17 +61,21 @@ async def test_create_and_link_read_pv(mocker: MockerFixture):
5161

5262

5363
@pytest.mark.asyncio
54-
async def test_create_and_link_read_pv_enum(mocker: MockerFixture):
64+
async def test_create_and_link_read_pv_enum(
65+
mocker: MockerFixture, ioc_without_mapping: EpicsIOC
66+
):
5567
get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record")
56-
add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info")
68+
add_attr_pvi_info = mocker.patch(
69+
"fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info"
70+
)
5771
attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum")
5872
record = get_input_record.return_value
5973
enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index")
6074

6175
attribute = mocker.MagicMock()
6276

6377
attr_is_enum.return_value = True
64-
_create_and_link_read_pv("PREFIX", "PV", "attr", attribute)
78+
ioc_without_mapping._create_and_link_read_pv("PREFIX", "PV", "attr", attribute)
6579

6680
get_input_record.assert_called_once_with("PREFIX:PV", attribute)
6781
add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r")
@@ -108,17 +122,21 @@ def test_get_input_record_raises(mocker: MockerFixture):
108122

109123

110124
@pytest.mark.asyncio
111-
async def test_create_and_link_write_pv(mocker: MockerFixture):
125+
async def test_create_and_link_write_pv(
126+
mocker: MockerFixture, ioc_without_mapping: EpicsIOC
127+
):
112128
get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record")
113-
add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info")
129+
add_attr_pvi_info = mocker.patch(
130+
"fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info"
131+
)
114132
attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum")
115133
record = get_output_record.return_value
116134

117135
attribute = mocker.MagicMock()
118136
attribute.process_without_display_update = mocker.AsyncMock()
119137

120138
attr_is_enum.return_value = False
121-
_create_and_link_write_pv("PREFIX", "PV", "attr", attribute)
139+
ioc_without_mapping._create_and_link_write_pv("PREFIX", "PV", "attr", attribute)
122140

123141
get_output_record.assert_called_once_with(
124142
"PREFIX:PV", attribute, on_update=mocker.ANY
@@ -140,9 +158,13 @@ async def test_create_and_link_write_pv(mocker: MockerFixture):
140158

141159

142160
@pytest.mark.asyncio
143-
async def test_create_and_link_write_pv_enum(mocker: MockerFixture):
161+
async def test_create_and_link_write_pv_enum(
162+
mocker: MockerFixture, ioc_without_mapping: EpicsIOC
163+
):
144164
get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record")
145-
add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info")
165+
add_attr_pvi_info = mocker.patch(
166+
"fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info"
167+
)
146168
attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum")
147169
enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index")
148170
enum_index_to_value = mocker.patch("fastcs.backends.epics.ioc.enum_index_to_value")
@@ -152,7 +174,7 @@ async def test_create_and_link_write_pv_enum(mocker: MockerFixture):
152174
attribute.process_without_display_update = mocker.AsyncMock()
153175

154176
attr_is_enum.return_value = True
155-
_create_and_link_write_pv("PREFIX", "PV", "attr", attribute)
177+
ioc_without_mapping._create_and_link_write_pv("PREFIX", "PV", "attr", attribute)
156178

157179
get_output_record.assert_called_once_with(
158180
"PREFIX:PV", attribute, on_update=mocker.ANY
@@ -215,22 +237,28 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping):
215237
builder = mocker.patch("fastcs.backends.epics.ioc.builder")
216238
add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info")
217239
add_sub_controller_pvi_info = mocker.patch(
218-
"fastcs.backends.epics.ioc._add_sub_controller_pvi_info"
240+
"fastcs.backends.epics.ioc.EpicsIOC._add_sub_controller_pvi_info"
219241
)
220242

221243
EpicsIOC(DEVICE, mapping)
222244

223245
# Check records are created
224246
builder.boolIn.assert_called_once_with(f"{DEVICE}:ReadBool", ZNAM="OFF", ONAM="ON")
225-
builder.longIn.assert_any_call(f"{DEVICE}:ReadInt")
226-
builder.aIn.assert_called_once_with(f"{DEVICE}:ReadWriteFloat_RBV", PREC=2)
247+
builder.longIn.assert_any_call(f"{DEVICE}:ReadInt", EGU=None)
248+
builder.aIn.assert_called_once_with(
249+
f"{DEVICE}:ReadWriteFloat_RBV", EGU=None, PREC=2
250+
)
227251
builder.aOut.assert_any_call(
228-
f"{DEVICE}:ReadWriteFloat", always_update=True, on_update=mocker.ANY, PREC=2
252+
f"{DEVICE}:ReadWriteFloat",
253+
always_update=True,
254+
on_update=mocker.ANY,
255+
EGU=None,
256+
PREC=2,
229257
)
230-
builder.longIn.assert_any_call(f"{DEVICE}:BigEnum")
231-
builder.longIn.assert_any_call(f"{DEVICE}:ReadWriteInt_RBV")
258+
builder.longIn.assert_any_call(f"{DEVICE}:BigEnum", EGU=None)
259+
builder.longIn.assert_any_call(f"{DEVICE}:ReadWriteInt_RBV", EGU=None)
232260
builder.longOut.assert_called_with(
233-
f"{DEVICE}:ReadWriteInt", always_update=True, on_update=mocker.ANY
261+
f"{DEVICE}:ReadWriteInt", always_update=True, on_update=mocker.ANY, EGU=None
234262
)
235263
builder.mbbIn.assert_called_once_with(
236264
f"{DEVICE}:StringEnum_RBV", ZRST="red", ONST="green", TWST="blue"
@@ -323,25 +351,27 @@ def test_add_pvi_info_with_parent(mocker: MockerFixture):
323351
)
324352

325353

326-
def test_add_sub_controller_pvi_info(mocker: MockerFixture):
354+
def test_add_sub_controller_pvi_info(
355+
mocker: MockerFixture, ioc_without_mapping: EpicsIOC
356+
):
327357
add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info")
328358
controller = mocker.MagicMock()
329359
controller.path = []
330360
child = mocker.MagicMock()
331361
child.path = ["Child"]
332362
controller.get_sub_controllers.return_value = {"d": child}
333363

334-
_add_sub_controller_pvi_info(DEVICE, controller)
364+
ioc_without_mapping._add_sub_controller_pvi_info(DEVICE, controller)
335365

336366
add_pvi_info.assert_called_once_with(
337367
f"{DEVICE}:Child:PVI", f"{DEVICE}:PVI", "child"
338368
)
339369

340370

341-
def test_add_attr_pvi_info(mocker: MockerFixture):
371+
def test_add_attr_pvi_info(mocker: MockerFixture, ioc_without_mapping: EpicsIOC):
342372
record = mocker.MagicMock()
343373

344-
_add_attr_pvi_info(record, DEVICE, "attr", "r")
374+
ioc_without_mapping._add_attr_pvi_info(record, DEVICE, "attr", "r")
345375

346376
record.add_info.assert_called_once_with(
347377
"Q:group",
@@ -387,13 +417,9 @@ def test_long_pv_names_discarded(mocker: MockerFixture):
387417

388418
short_pv_name = "attr_rw_short_name".title().replace("_", "")
389419
builder.longOut.assert_called_once_with(
390-
f"{DEVICE}:{short_pv_name}",
391-
always_update=True,
392-
on_update=mocker.ANY,
393-
)
394-
builder.longIn.assert_called_once_with(
395-
f"{DEVICE}:{short_pv_name}_RBV",
420+
f"{DEVICE}:{short_pv_name}", always_update=True, on_update=mocker.ANY, EGU=None
396421
)
422+
builder.longIn.assert_called_once_with(f"{DEVICE}:{short_pv_name}_RBV", EGU=None)
397423

398424
long_pv_name = long_attr_name.title().replace("_", "")
399425
with pytest.raises(AssertionError):

tests/test_backend.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from time import sleep
1+
import asyncio
22

33
import pytest
44

@@ -16,7 +16,7 @@ async def init_task(self):
1616
self.init_task_called = True
1717

1818
def _run(self):
19-
pass
19+
asyncio.run_coroutine_threadsafe(asyncio.sleep(0.3), self._loop)
2020

2121

2222
@pytest.mark.asyncio
@@ -41,5 +41,7 @@ async def test_backend(controller):
4141
# Scan tasks should be running
4242
for _ in range(3):
4343
count = controller.count
44-
sleep(0.05)
44+
await asyncio.sleep(0.1)
4545
assert controller.count > count
46+
47+
backend.stop_scan_tasks()

0 commit comments

Comments
 (0)