Skip to content

Commit e493f80

Browse files
Add Listener/Client synchronization to tests
These tests, especially the asyncio one, were prone to timing issues where record processing and prints to stdout could result in race conditions. This is alleviated by using signalling between parent and child processes to ensure synchronization.
1 parent 75d2300 commit e493f80

File tree

6 files changed

+219
-121
lines changed

6 files changed

+219
-121
lines changed

tests/conftest.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
WAVEFORM_LENGTH = 40
2121

2222
# Default timeout for many operations across testing
23-
TIMEOUT = 10 # Seconds
23+
TIMEOUT = 5 # Seconds
24+
25+
# Address for multiprocessing Listener/Client pair
26+
ADDRESS = ("localhost", 2345)
2427

2528
def create_random_prefix():
2629
"""Create 12-character random string, for generating unique Device Names"""
@@ -103,13 +106,22 @@ def enable_code_coverage():
103106
else:
104107
cleanup_on_sigterm()
105108

109+
106110
def select_and_recv(conn, expected_char = None):
107111
"""Wait for the given Connection to have data to receive, and return it.
108-
If a character is provided check its correct before returning it.
109-
This function imports Cothread, and so must NOT be called before any
110-
multiprocessing sub-processes are spawned."""
111-
from cothread import select
112-
rrdy, _, _ = select([conn], [], [], TIMEOUT)
112+
If a character is provided check its correct before returning it."""
113+
# Must use cothread's select if cothread is prsent, otherwise we'd block
114+
# processing on all cothread processing. But we don't want to use it
115+
# unless we have to, as importing cothread can cause issues with forking.
116+
if "cothread" in sys.modules:
117+
from cothread import select
118+
rrdy, _, _ = select([conn], [], [], TIMEOUT)
119+
else:
120+
# Would use select.select(), but Windows doesn't accept Pipe handles
121+
# as selectable objects.
122+
if conn.poll(TIMEOUT):
123+
rrdy = True
124+
113125
if rrdy:
114126
val = conn.recv()
115127
else:

tests/sim_asyncio_ioc.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from argparse import ArgumentParser
2-
31
import asyncio
4-
import time
52
import sys
63

4+
from argparse import ArgumentParser
5+
from multiprocessing.connection import Client
6+
77
from softioc import alarm, softioc, builder, asyncio_dispatcher, pvlog
88

9+
from conftest import ADDRESS, select_and_recv
910

1011
if __name__ == "__main__":
1112
# Being run as an IOC, so parse args and set prefix
@@ -16,28 +17,40 @@
1617

1718
import sim_records
1819

19-
async def callback(value):
20-
# Set the ao value, which will trigger on_update for it
21-
sim_records.t_ao.set(value)
22-
await asyncio.sleep(0.5)
23-
print("async update %s (%s)" % (value, sim_records.t_ai.get()))
24-
# Make sure it goes as epicsExit will not flush this for us
20+
with Client(ADDRESS) as conn:
21+
22+
async def callback(value):
23+
# Set the ao value, which will trigger on_update for it
24+
sim_records.t_ao.set(value)
25+
print("async update %s (%s)" % (value, sim_records.t_ai.get()))
26+
# Make sure it goes as epicsExit will not flush this for us
27+
sys.stdout.flush()
28+
# Set the ai alarm, but keep the value
29+
sim_records.t_ai.set_alarm(int(value) % 4, alarm.STATE_ALARM)
30+
# Must give the t_ai record time to process
31+
await asyncio.sleep(1)
32+
conn.send("C") # "Complete"
33+
34+
# Set a different initial value
35+
sim_records.t_ai.set(23.45)
36+
37+
# Create a record to set the alarm
38+
t_ao = builder.aOut('ALARM', on_update=callback)
39+
40+
# Run the IOC
41+
builder.LoadDatabase()
42+
softioc.iocInit(asyncio_dispatcher.AsyncioDispatcher())
43+
44+
conn.send("R") # "Ready"
45+
46+
# Make sure coverage is written on epicsExit
47+
from pytest_cov.embed import cleanup
48+
sys._run_exitfuncs = cleanup
49+
50+
select_and_recv(conn, "D") # "Done"
51+
# Attempt to ensure all buffers flushed - C code (from `import pvlog`)
52+
# may not be affected by these calls...
2553
sys.stdout.flush()
26-
# Set the ai alarm, but keep the value
27-
sim_records.t_ai.set_alarm(int(value) % 4, alarm.STATE_ALARM)
28-
29-
# Set a different initial value
30-
sim_records.t_ai.set(23.45)
31-
32-
# Create a record to set the alarm
33-
t_ao = builder.aOut('ALARM', on_update=callback)
34-
35-
# Run the IOC
36-
builder.LoadDatabase()
37-
softioc.iocInit(asyncio_dispatcher.AsyncioDispatcher())
38-
# Wait for some prints to have happened
39-
time.sleep(1)
40-
# Make sure coverage is written on epicsExit
41-
from pytest_cov.embed import cleanup
42-
sys._run_exitfuncs = cleanup
43-
softioc.interactive_ioc()
54+
sys.stderr.flush()
55+
56+
conn.send("D") # "Done"

tests/sim_asyncio_ioc_override.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
from argparse import ArgumentParser
2-
31
import asyncio
42
import os
53
import re
4+
import sys
5+
66
from pathlib import Path
77
from tempfile import NamedTemporaryFile
8+
from argparse import ArgumentParser
9+
from multiprocessing.connection import Client
810

911
from softioc import softioc, builder, asyncio_dispatcher
1012

13+
from conftest import ADDRESS, select_and_recv
1114

1215
if __name__ == "__main__":
1316
# Being run as an IOC, so parse args and set prefix
@@ -34,4 +37,18 @@
3437
builder.LoadDatabase()
3538
event_loop = asyncio.get_event_loop()
3639
softioc.iocInit(asyncio_dispatcher.AsyncioDispatcher(event_loop))
37-
event_loop.run_forever()
40+
41+
with Client(ADDRESS) as conn:
42+
conn.send("R") # "Ready"
43+
44+
# Make sure coverage is written on epicsExit
45+
from pytest_cov.embed import cleanup
46+
sys._run_exitfuncs = cleanup
47+
48+
select_and_recv(conn, "D") # "Done"
49+
# Attempt to ensure all buffers flushed - C code (from `import pvlog`)
50+
# may not be affected by these calls...
51+
sys.stdout.flush()
52+
sys.stderr.flush()
53+
54+
conn.send("D") # "Done"

tests/sim_cothread_ioc.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from argparse import ArgumentParser
2+
from multiprocessing.connection import Client
3+
import sys
24

35
from softioc import softioc, builder, pvlog
46

7+
from conftest import ADDRESS, select_and_recv
58

69
if __name__ == "__main__":
710
import cothread
@@ -17,4 +20,14 @@
1720
# Run the IOC
1821
builder.LoadDatabase()
1922
softioc.iocInit()
20-
cothread.WaitForQuit()
23+
24+
with Client(ADDRESS) as conn:
25+
conn.send("R") # "Ready"
26+
27+
select_and_recv(conn, "D") # "Done"
28+
# Attempt to ensure all buffers flushed - C code (from `import pvlog`)
29+
# may not be affected by these calls...
30+
sys.stdout.flush()
31+
sys.stderr.flush()
32+
33+
conn.send("D") # "Ready"

tests/test_asyncio.py

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,56 @@
1-
# Will be ignored on Python2 by conftest.py settings
2-
31
import pytest
42
import sys
53

4+
from multiprocessing.connection import Listener
5+
6+
from conftest import requires_cothread, ADDRESS, select_and_recv
67

78
@pytest.mark.asyncio
89
async def test_asyncio_ioc(asyncio_ioc):
910
import asyncio
1011
from aioca import caget, caput, camonitor, CANothing, FORMAT_TIME
1112

12-
# Start
1313
pre = asyncio_ioc.pv_prefix
14-
assert (await caget(pre + ":UPTIME")).startswith("00:00:0")
15-
# WAVEFORM
16-
await caput(pre + ":SINN", 4, wait=True)
17-
q = asyncio.Queue()
18-
m = camonitor(pre + ":SIN", q.put, notify_disconnect=True)
19-
assert len(await asyncio.wait_for(q.get(), 1)) == 4
20-
# AO
21-
ao_val = await caget(pre + ":ALARM", format=FORMAT_TIME)
22-
assert ao_val == 0
23-
assert ao_val.severity == 3 # INVALID
24-
assert ao_val.status == 17 # UDF
25-
await caput(pre + ":ALARM", 3, wait=True)
26-
await asyncio.sleep(0.1)
27-
ai_val = await caget(pre + ":AI", format=FORMAT_TIME)
28-
assert ai_val == 23.45
29-
assert ai_val.severity == 0
30-
assert ai_val.status == 0
31-
await asyncio.sleep(0.8)
32-
ai_val = await caget(pre + ":AI", format=FORMAT_TIME)
33-
assert ai_val == 23.45
34-
assert ai_val.severity == 3
35-
assert ai_val.status == 7 # STATE_ALARM
36-
# Check pvaccess works
37-
from p4p.client.asyncio import Context
38-
with Context("pva") as ctx:
39-
assert await ctx.get(pre + ":AI") == 23.45
40-
# Wait for a bit longer for the print output to flush
41-
await asyncio.sleep(2)
14+
15+
with Listener(ADDRESS) as listener, listener.accept() as conn:
16+
17+
select_and_recv(conn, "R") # "Ready"
18+
19+
# Start
20+
assert (await caget(pre + ":UPTIME")).startswith("00:00:0")
21+
# WAVEFORM
22+
await caput(pre + ":SINN", 4, wait=True)
23+
q = asyncio.Queue()
24+
m = camonitor(pre + ":SIN", q.put, notify_disconnect=True)
25+
assert len(await asyncio.wait_for(q.get(), 1)) == 4
26+
# AO
27+
ao_val = await caget(pre + ":ALARM", format=FORMAT_TIME)
28+
assert ao_val == 0
29+
assert ao_val.severity == 3 # INVALID
30+
assert ao_val.status == 17 # UDF
31+
32+
ai_val = await caget(pre + ":AI", format=FORMAT_TIME)
33+
assert ai_val == 23.45
34+
assert ai_val.severity == 0
35+
assert ai_val.status == 0
36+
37+
await caput(pre + ":ALARM", 3, wait=True)
38+
39+
# Confirm the ALARM callback has completed
40+
select_and_recv(conn, "C") # "Complete"
41+
42+
ai_val = await caget(pre + ":AI", format=FORMAT_TIME)
43+
assert ai_val == 23.45
44+
assert ai_val.severity == 3
45+
assert ai_val.status == 7 # STATE_ALARM
46+
# Check pvaccess works
47+
from p4p.client.asyncio import Context
48+
with Context("pva") as ctx:
49+
assert await ctx.get(pre + ":AI") == 23.45
50+
51+
conn.send("D") # "Done"
52+
select_and_recv(conn, "D") # "Done"
53+
4254
# Stop
4355
out, err = asyncio_ioc.proc.communicate(b"exit\n", timeout=5)
4456
out = out.decode()
@@ -47,14 +59,19 @@ async def test_asyncio_ioc(asyncio_ioc):
4759
assert isinstance(await asyncio.wait_for(q.get(), 10), CANothing)
4860
m.close()
4961
# check closed and output
50-
assert "%s:SINN.VAL 1024 -> 4" % pre in out
51-
assert 'update_sin_wf 4' in out
52-
assert "%s:ALARM.VAL 0 -> 3" % pre in out
53-
assert 'on_update %s:AO : 3.0' % pre in out
54-
assert 'async update 3.0 (23.45)' in out
55-
assert 'Starting iocInit' in err
56-
assert 'iocRun: All initialization complete' in err
57-
assert '(InteractiveConsole)' in err
62+
try:
63+
assert "%s:SINN.VAL 1024 -> 4" % pre in out
64+
assert 'update_sin_wf 4' in out
65+
assert "%s:ALARM.VAL 0 -> 3" % pre in out
66+
assert 'on_update %s:AO : 3.0' % pre in out
67+
assert 'async update 3.0 (23.45)' in out
68+
assert 'Starting iocInit' in err
69+
assert 'iocRun: All initialization complete' in err
70+
except Exception:
71+
# Useful printing for when things go wrong!
72+
print("Out:", out)
73+
print("Err:", err)
74+
raise
5875

5976

6077
@pytest.mark.asyncio
@@ -64,20 +81,34 @@ async def test_asyncio_ioc(asyncio_ioc):
6481
async def test_asyncio_ioc_override(asyncio_ioc_override):
6582
from aioca import caget, caput
6683

67-
# Gain bo
68-
pre = asyncio_ioc_override.pv_prefix
69-
assert (await caget(pre + ":GAIN")) == 0
70-
await caput(pre + ":GAIN", "On", wait=True)
71-
assert (await caget(pre + ":GAIN")) == 1
84+
with Listener(ADDRESS) as listener, listener.accept() as conn:
85+
86+
select_and_recv(conn, "R") # "Ready"
87+
88+
# Gain bo
89+
pre = asyncio_ioc_override.pv_prefix
90+
assert (await caget(pre + ":GAIN")) == 0
91+
await caput(pre + ":GAIN", "On", wait=True)
92+
assert (await caget(pre + ":GAIN")) == 1
93+
94+
# Stop
95+
await caput(pre + ":SYSRESET", 1)
96+
97+
conn.send("D") # "Done"
98+
select_and_recv(conn, "D") # "Done"
7299

73-
# Stop
74-
await caput(pre + ":SYSRESET", 1)
75100
# check closed and output
76101
out, err = asyncio_ioc_override.proc.communicate(timeout=5)
77102
out = out.decode()
78103
err = err.decode()
79104
# check closed and output
80-
assert '1' in out
81-
assert 'Starting iocInit' in err
82-
assert 'iocRun: All initialization complete' in err
83-
assert 'IOC reboot started' in err
105+
try:
106+
assert '1' in out
107+
assert 'Starting iocInit' in err
108+
assert 'iocRun: All initialization complete' in err
109+
assert 'IOC reboot started' in err
110+
except Exception:
111+
# Useful printing for when things go wrong!
112+
print("Out:", out)
113+
print("Err:", err)
114+
raise

0 commit comments

Comments
 (0)