Skip to content

Commit 3126269

Browse files
Merge pull request #202 from DiamondLightSource/reimplement_db_put_field
Fix infinite loop when setting record's own value from on_update callback
2 parents 09ea964 + 8034e3a commit 3126269

5 files changed

Lines changed: 224 additions & 21 deletions

File tree

CHANGELOG.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Versioning <https://semver.org/spec/v2.0.0.html>`_.
1010
Unreleased_
1111
-----------
1212

13-
Nothing yet!
13+
Fixed:
14+
15+
- `Fix infinite loop when setting record's own value from on_update callback <../../pull/202>`_
1416

1517
4.7.0_ - 2026-01-14
1618
-------------------

softioc/device.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
dbLoadDatabase,
1212
signal_processing_complete,
1313
recGblResetAlarms,
14-
db_put_field,
14+
db_put_field_process,
1515
db_get_field,
1616
)
1717
from .device_core import DeviceSupportCore, RecordLookup
@@ -108,7 +108,7 @@ def set_field(self, field, value):
108108
data = (c_char * 40)()
109109
data.value = str(value).encode() + b'\0'
110110
name = self._name + '.' + field
111-
db_put_field(name, fields.DBF_STRING, addressof(data), 1)
111+
db_put_field_process(name, fields.DBF_STRING, addressof(data), 1, True)
112112

113113
class ProcessDeviceSupportIn(ProcessDeviceSupportCore):
114114
_link_ = 'INP'
@@ -178,7 +178,6 @@ def __init__(self, name, **kargs):
178178

179179
self.__validate = kargs.pop('validate', None)
180180
self.__always_update = kargs.pop('always_update', False)
181-
self.__enable_write = True
182181

183182
if 'initial_value' in kargs:
184183
value = self._value_to_epics(kargs.pop('initial_value'))
@@ -239,8 +238,7 @@ def _process(self, record):
239238
return EPICS_OK
240239

241240
python_value = self._epics_to_value(value)
242-
if self.__enable_write and self.__validate and \
243-
not self.__validate(self, python_value):
241+
if self.__validate and not self.__validate(self, python_value):
244242
# Asynchronous validation rejects value, so restore the last good
245243
# value.
246244
self._write_value(record, self._value[0])
@@ -249,7 +247,7 @@ def _process(self, record):
249247
# Value is good. Hang onto it, let users know the value has changed
250248
self._value = (value, severity, alarm)
251249
record.UDF = 0
252-
if self.__on_update and self.__enable_write:
250+
if self.__on_update:
253251
record.PACT = self._blocking
254252
dispatcher(
255253
self.__on_update,
@@ -287,9 +285,15 @@ def set(self, value, process=True,
287285
else:
288286
# The array parameter is used to keep the raw pointer alive
289287
dbf_code, length, data, array = self._value_to_dbr(value)
290-
self.__enable_write = process
291-
db_put_field(_record.NAME, dbf_code, data, length)
292-
self.__enable_write = True
288+
289+
# If we do process we instead do this inside _process, allowing
290+
# validation to potentially refuse the update.
291+
# However if we do not process, we must do this here to keep the
292+
# Python and EPICS values in line
293+
if not process:
294+
self._value = (value, severity, alarm)
295+
296+
db_put_field_process(_record.NAME, dbf_code, data, length, process)
293297

294298
def get(self):
295299
return self._epics_to_value(self._value[0])

softioc/extension.c

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,18 @@ static PyObject *get_field_offsets(PyObject *self, PyObject *args)
9393
}
9494

9595

96-
/* Updates PV field with integrated db lookup. Safer to do this in C as we need
97-
* an intermediate copy of the dbAddr structure, which changes size between
98-
* EPICS releases. */
99-
static PyObject *db_put_field(PyObject *self, PyObject *args)
96+
/* This is our own re-implementation of EPICS's dbPutField function.
97+
* We do this to allow us to control when dbProcess is called. We use the
98+
* same logicical flow as the original function. */
99+
static PyObject *db_put_field_process(PyObject *self, PyObject *args)
100100
{
101101
const char *name;
102102
short dbrType;
103103
PyObject *buffer_ptr;
104104
long length;
105-
if (!PyArg_ParseTuple(args, "shOl", &name, &dbrType, &buffer_ptr, &length))
105+
short process;
106+
if (!PyArg_ParseTuple(args, "shOlh",
107+
&name, &dbrType, &buffer_ptr, &length, &process))
106108
return NULL;
107109
void *pbuffer = PyLong_AsVoidPtr(buffer_ptr);
108110
if (!pbuffer)
@@ -113,6 +115,8 @@ static PyObject *db_put_field(PyObject *self, PyObject *args)
113115
return PyErr_Format(
114116
PyExc_RuntimeError, "dbNameToAddr failed for %s", name);
115117

118+
struct dbCommon *precord = dbAddr.precord;
119+
116120
long put_result;
117121
/* There are two important locks to consider at this point: The Global
118122
* Interpreter Lock (GIL) and the EPICS record lock. A deadlock is possible
@@ -125,7 +129,18 @@ static PyObject *db_put_field(PyObject *self, PyObject *args)
125129
* EPICS call, to avoid potential deadlocks.
126130
* See https://github.com/DiamondLightSource/pythonSoftIOC/issues/119. */
127131
Py_BEGIN_ALLOW_THREADS
128-
put_result = dbPutField(&dbAddr, dbrType, pbuffer, length);
132+
dbScanLock(precord);
133+
put_result = dbPut(&dbAddr, dbrType, pbuffer, length);
134+
135+
if (put_result == 0 && process)
136+
{
137+
if (precord->pact)
138+
precord->rpro = TRUE;
139+
else
140+
dbProcess(precord);
141+
}
142+
143+
dbScanUnlock(precord);
129144
Py_END_ALLOW_THREADS
130145
if (put_result)
131146
return PyErr_Format(
@@ -314,7 +329,7 @@ static struct PyMethodDef softioc_methods[] = {
314329
"Get a map of DBF names to values"},
315330
{"get_field_offsets", get_field_offsets, METH_VARARGS,
316331
"Get offset, size and type for each record field"},
317-
{"db_put_field", db_put_field, METH_VARARGS,
332+
{"db_put_field_process", db_put_field_process, METH_VARARGS,
318333
"Put a database field to a value"},
319334
{"db_get_field", db_get_field, METH_VARARGS,
320335
"Get a database field's value"},

softioc/imports.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ def get_field_offsets(record_type):
1919
'''Return {field_name: (offset, size, field_type)}'''
2020
return _extension.get_field_offsets(record_type)
2121

22-
def db_put_field(name, dbr_type, pbuffer, length):
23-
'''Put field where pbuffer is void* pointer. Returns None.'''
24-
return _extension.db_put_field(name, dbr_type, pbuffer, length)
22+
def db_put_field_process(name, dbr_type, pbuffer, length, process):
23+
'''Put field where pbuffer is void* pointer, conditionally processing
24+
the record. Returns None.'''
25+
return _extension.db_put_field_process(
26+
name, dbr_type, pbuffer, length, process
27+
)
2528

2629
def db_get_field(name, dbr_type, pbuffer, length):
2730
'''Get field where pbuffer is void* pointer. Returns None.'''

tests/test_records.py

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,12 @@ def out_records(self, request):
380380

381381
def validate_always_pass(self, record, new_val):
382382
"""Validation method that always allows changes"""
383+
log("VALIDATE: Returning True")
383384
return True
384385

385386
def validate_always_fail(self, record, new_val):
386387
"""Validation method that always rejects changes"""
388+
log("VALIDATE: Returning False")
387389
return False
388390

389391
def validate_ioc_test_func(
@@ -445,7 +447,6 @@ def validate_test_runner(
445447
# Wait for message that IOC has started
446448
select_and_recv(parent_conn, "R")
447449

448-
449450
# Suppress potential spurious warnings
450451
_channel_cache.purge()
451452

@@ -682,6 +683,95 @@ def test_on_update_true_false(self, out_records):
682683
always_update is True and the put'ed value is always different"""
683684
self.on_update_runner(out_records, True, False)
684685

686+
def on_update_recursive_set_test_func(
687+
self, device_name, conn
688+
):
689+
log("CHILD: Child started")
690+
691+
builder.SetDeviceName(device_name)
692+
693+
async def on_update_func(new_val):
694+
log("CHILD: on_update_func started")
695+
record.set(0, process=False)
696+
conn.send("C") # "Callback"
697+
log("CHILD: on_update_func ended")
698+
699+
record = builder.Action(
700+
"ACTION",
701+
on_update=on_update_func,
702+
blocking=True,
703+
initial_value=1 # A non-zero value, to check it changes
704+
)
705+
706+
dispatcher = asyncio_dispatcher.AsyncioDispatcher()
707+
builder.LoadDatabase()
708+
softioc.iocInit(dispatcher)
709+
710+
conn.send("R") # "Ready"
711+
712+
log("CHILD: Sent R over Connection to Parent")
713+
714+
# Keep process alive while main thread runs CAGET
715+
if conn.poll(TIMEOUT):
716+
val = conn.recv()
717+
assert val == "D", "Did not receive expected Done character"
718+
719+
log("CHILD: Received exit command, child exiting")
720+
721+
async def test_on_update_recursive_set(self):
722+
"""Test that on_update functions correctly when the on_update
723+
callback sets the value of the record again (with process=False).
724+
See issue #201"""
725+
726+
ctx = get_multiprocessing_context()
727+
parent_conn, child_conn = ctx.Pipe()
728+
729+
device_name = create_random_prefix()
730+
731+
process = ctx.Process(
732+
target=self.on_update_recursive_set_test_func,
733+
args=(device_name, child_conn),
734+
)
735+
736+
process.start()
737+
738+
log("PARENT: Child started, waiting for R command")
739+
740+
from aioca import caget, caput
741+
742+
try:
743+
# Wait for message that IOC has started
744+
select_and_recv(parent_conn, "R")
745+
746+
log("PARENT: received R command")
747+
748+
record = f"{device_name}:ACTION"
749+
750+
val = await caget(record)
751+
752+
assert val == 1, "ACTION record did not start with value 1"
753+
754+
await caput(record, 1, wait=True)
755+
756+
val = await caget(record)
757+
758+
assert val == 0, "ACTION record did not return to zero value"
759+
760+
# Expect one "C"
761+
select_and_recv(parent_conn, "C")
762+
763+
# ...But if we receive another we know there's a problem
764+
if parent_conn.poll(5): # Shorter timeout to make this quicker
765+
pytest.fail("Received unexpected second message")
766+
767+
finally:
768+
log("PARENT:Sending Done command to child")
769+
parent_conn.send("D") # "Done"
770+
process.join(timeout=TIMEOUT)
771+
log(f"PARENT: Join completed with exitcode {process.exitcode}")
772+
if process.exitcode is None:
773+
pytest.fail("Process did not terminate")
774+
685775

686776

687777
class TestBlocking:
@@ -1463,3 +1553,92 @@ def test_set_alarm_severity_status(self, set_enum):
14631553
_channel_cache.purge()
14641554
parent_conn.send("D") # "Done"
14651555
process.join(timeout=TIMEOUT)
1556+
1557+
1558+
class TestProcess:
1559+
"""Tests related to processing - checking values are as expected
1560+
between the EPICS and Python layers. """
1561+
1562+
test_result_rec = "TestResult"
1563+
1564+
1565+
def process_test_function(self, device_name, conn, process):
1566+
builder.SetDeviceName(device_name)
1567+
1568+
rec = builder.longOut("TEST", initial_value=5)
1569+
1570+
# Record to indicate success/failure
1571+
bi = builder.boolIn(self.test_result_rec, ZNAM="FAILED", ONAM="SUCCESS")
1572+
1573+
builder.LoadDatabase()
1574+
softioc.iocInit()
1575+
1576+
# Prove value changes from .set call
1577+
rec.set(10, process=process)
1578+
1579+
conn.send("R") # "Ready"
1580+
log("CHILD: Sent R over Connection to Parent")
1581+
1582+
select_and_recv(conn, "R")
1583+
1584+
val = rec.get()
1585+
log(f"CHILD: record value is {val}")
1586+
1587+
# value should be that which was set by .set()
1588+
if val == 10:
1589+
bi.set(1)
1590+
else:
1591+
bi.set(0)
1592+
1593+
# Keep process alive while main thread works.
1594+
while (True):
1595+
if conn.poll(TIMEOUT):
1596+
val = conn.recv()
1597+
if val == "D": # "Done"
1598+
break
1599+
1600+
@requires_cothread
1601+
@pytest.mark.parametrize("process", [True, False])
1602+
def test_set_alarm_severity_status(self, process):
1603+
"""Test that set_alarm function allows setting severity and status"""
1604+
ctx = get_multiprocessing_context()
1605+
parent_conn, child_conn = ctx.Pipe()
1606+
1607+
device_name = create_random_prefix()
1608+
1609+
process = ctx.Process(
1610+
target=self.process_test_function,
1611+
args=(device_name, child_conn, process),
1612+
)
1613+
1614+
process.start()
1615+
1616+
from cothread.catools import caget, _channel_cache
1617+
from cothread import Sleep
1618+
1619+
try:
1620+
# Wait for message that IOC has started
1621+
select_and_recv(parent_conn, "R")
1622+
1623+
# Suppress potential spurious warnings
1624+
_channel_cache.purge()
1625+
1626+
record = device_name + ":TEST"
1627+
val = caget(record, timeout=TIMEOUT)
1628+
1629+
assert val == 10
1630+
1631+
parent_conn.send("R") # "Ready"
1632+
1633+
Sleep(0.5) # Give child time to process update
1634+
1635+
result_record = device_name + f":{self.test_result_rec}"
1636+
val = caget(result_record)
1637+
1638+
assert val == 1, "Success record indicates failure"
1639+
1640+
finally:
1641+
# Suppress potential spurious warnings
1642+
_channel_cache.purge()
1643+
parent_conn.send("D") # "Done"
1644+
process.join(timeout=TIMEOUT)

0 commit comments

Comments
 (0)