Threaded version 0.7

This commit is contained in:
2026-10-06 23:50:07 -06:00
parent 490c9bdc5b
commit c39c226885
15 changed files with 13126 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Real PTY/socket integration tests for the transport RTU and TCP server bridge."""
import ctypes as C,os,pty,select,socket,struct,subprocess,time,tempfile
from pathlib import Path
root=Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as tmp:
so=Path(tmp)/'test-transport.so'
subprocess.run(['cc','-shared','-fPIC','-std=c11','-Wall','-Wextra','-Werror','-O2','-pthread','-I'+str(root/'src'),*[str(root/'src'/f) for f in ['atom_mb.c','atom_rtu.c','atom_server.c','atom_bridge.c','atom_worker.c']],'-o',str(so)],check=True)
lib=C.CDLL(str(so));fn=lib.ab_call
U=C.c_uint32;W=C.c_uint16;B=C.c_uint8
fn.argtypes=[U,U,U,W,B,B,W,W,C.POINTER(W),U,C.POINTER(U),U,C.c_uint64,U,U,U];fn.restype=U
data=(W*2000)();status=(U*24)()
def now():return int(time.monotonic()*1000)
def call(cmd,h=0,ip=0,port=0,unit=1,f=3,addr=0,q=1,ct=100,rt=100,retry=10,when=None):
return fn(cmd,h,ip,port,unit,f,addr,q,data,2000,status,24,now() if when is None else when,ct,rt,retry)
def crc(frame):
v=0xffff
for byte in frame:
v^=byte
for _ in range(8):v=(v>>1)^(0xa001 if v&1 else 0)
return v
def adu(payload):return payload+struct.pack('<H',crc(payload))
def ready(h):
end=time.monotonic()+1
while time.monotonic()<end:
assert call(5,h)==0
if status[1]==3:return
time.sleep(.001)
raise AssertionError(('not ready',list(status)))
def rtu_scenario(mode,f=3):
master,slave=pty.openpty();path=os.ttyname(slave);os.close(slave);os.set_blocking(master,False)
for i,b in enumerate(path.encode()):data[i]=b
assert call(20,ip=9600,unit=0,f=8,addr=1,q=len(path),ct=0)==0
h=status[0];assert call(9,h)==0;assert call(3,h)==0;ready(h)
for attempt in range(2 if mode in ['crc','timeout','echo'] else 1):
if f==16:data[0]=123;data[1]=456
if f==15:data[0]=1;data[1]=0
assert call(6,h,unit=1,f=f,addr=10,q=2)==0
received=b'';sent=False;fragments=[];deadline=time.monotonic()+1
while time.monotonic()<deadline:
assert call(5,h)==0
if select.select([master],[],[],0)[0]:
try:received+=os.read(master,256)
except OSError:pass
need=(9+received[6]) if len(received)>=7 and f>=15 else 8
if len(received)>=need and not sent:
assert crc(received)==0;sent=True
payload=(bytes([1,f])+received[2:6]) if f>=15 else bytes([1,f,4,0,42,0,43])
if mode=='exception':payload=bytes([1,f|0x80,2])
if mode=='echo' and attempt==0:payload=payload[:3]+bytes([payload[3]^1])+payload[4:]
response=adu(payload)
if mode=='crc' and attempt==0:response=response[:-1]+bytes([response[-1]^1])
if mode!='timeout' or attempt>0:
fragments=[response[:2],response[2:4],response[4:]] if mode=='fragment' else [response]
if fragments:os.write(master,fragments.pop(0))
if status[3] or status[4]:break
time.sleep(.002)
assert sent and (status[3] or status[4]),list(status)
if mode in ['crc','echo'] and attempt==0:assert status[5]==8
elif mode=='timeout' and attempt==0:assert status[5]==6
elif mode=='exception':assert status[5]==9 and status[8]==2 and status[1]==3
else:
assert status[3]==1,list(status)
if f<=4:assert call(7,h)==0 and list(data[:2])==[42,43]
assert call(10,h)==0
if attempt==0 and mode in ['crc','timeout','echo']:ready(h)
assert call(6,h,unit=0,f=3,addr=0,q=1)==1 # Broadcast unsupported, no fake completion.
assert call(2,h)==0;assert call(5,h)==256;os.close(master)
for mode in ['normal','fragment','crc','timeout','exception']:rtu_scenario(mode);print('PASS RTU',mode)
rtu_scenario('normal',16);print('PASS RTU register write')
rtu_scenario('echo',16);print('PASS RTU invalid echo and reopen recovery')
rtu_scenario('normal',15);print('PASS RTU coil write')
# Transport TCP server, four independent tables mapped at nonzero Modbus address.
probe=socket.socket();probe.bind(('127.0.0.1',0));port=probe.getsockname()[1];probe.close()
assert call(30,ip=0x7f000001,port=port)==0;h=status[0]
for table in range(4):
for i in range(16):data[i]=i%2 if table<2 else 100+i
assert call(35,h,f=table,addr=10,q=16)==0
assert call(32,h)==0
deadline=time.monotonic()+1
while time.monotonic()<deadline:
assert call(34,h)==0
if status[1]:break
time.sleep(.002)
assert status[1]==1
peer=socket.create_connection(('127.0.0.1',port));peer.setblocking(False)
tid=0
def exchange(pdu):
global tid
tid+=1;packet=struct.pack('>HHHB',tid,0,len(pdu)+1,1)+pdu
peer.send(packet[:3]);assert call(34,h)==0;peer.send(packet[3:])
reply=b'';end=time.monotonic()+1
while time.monotonic()<end:
assert call(34,h)==0
try:reply+=peer.recv(260)
except BlockingIOError:pass
if len(reply)>=7 and len(reply)==6+struct.unpack('>H',reply[4:6])[0]:return reply[7:]
time.sleep(.001)
raise AssertionError(('server timeout',pdu,reply))
for f in [1,2,3,4]:
rep=exchange(struct.pack('>BHH',f,10,2));assert rep==(bytes([f,1,2]) if f<=2 else bytes([f,4,0,100,0,101]));print('PASS slave read FC',f)
for f in [5,6]:
value=0xff00 if f==5 else 456;pdu=struct.pack('>BHH',f,10,value);assert exchange(pdu)==pdu
assert call(36,h,f=0 if f==5 else 2,addr=10,q=16)==0 and data[0]==(1 if f==5 else 456);print('PASS slave write FC',f)
for f in [15,16]:
payload=b'\x01' if f==15 else struct.pack('>HH',321,654)
pdu=struct.pack('>BHHB',f,10,2,len(payload))+payload
assert exchange(pdu)==struct.pack('>BHH',f,10,2)
assert call(36,h,f=0 if f==15 else 2,addr=10,q=16)==0 and list(data[:2])==([1,0] if f==15 else [321,654]);print('PASS slave write FC',f)
assert exchange(struct.pack('>BHH',3,0,1))==bytes([0x83,2])
assert exchange(struct.pack('>BHH',16,10,0))==bytes([0x90,3])
assert exchange(struct.pack('>BHH',99,10,1))==bytes([0xe3,1])
print('PASS slave exceptions and nonzero table bases')
assert call(31,h)==0;assert call(34,h)==256;peer.close()
# Listener bind error recovers after the conflicting listener disappears.
blocker=socket.socket();blocker.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);blocker.bind(('127.0.0.1',port));blocker.listen()
assert call(30,ip=0x7f000001,port=port)==0;h=status[0];call(32,h)
end=time.monotonic()+1
while time.monotonic()<end:
call(34,h)
if status[2]:break
time.sleep(.002)
assert status[2]==1
blocker.close()
end=time.monotonic()+1
while time.monotonic()<end:
call(34,h)
if status[1] and not status[2]:break
time.sleep(.002)
assert status[1]==1 and status[2]==0
call(31,h);print('PASS slave listener recovery and stale handles')
lib.ab_shutdown()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Exercise production worker with real sockets/PTYS and deliberately sparse PLC calls."""
import ctypes as C
import os,pty,select,socket,struct,subprocess,tempfile,time,threading
from pathlib import Path
root=Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as td:
so=Path(td)/'worker.so'
subprocess.run(['cc','-shared','-fPIC','-std=c11','-Wall','-Wextra','-Werror','-O2','-pthread',
*[str(root/'src'/f) for f in ['atom_mb.c','atom_rtu.c','atom_server.c','atom_bridge.c','atom_worker.c']],'-o',str(so)],check=True)
lib=C.CDLL(str(so));U=C.c_uint32;W=C.c_uint16;B=C.c_uint8
lib.ab_call.argtypes=[U,U,U,W,B,B,W,W,C.POINTER(W),U,C.POINTER(U),U,C.c_uint64,U,U,U];lib.ab_call.restype=U
def call(cmd,h=0,ip=0,port=0,f=3,addr=0,q=1,values=(),ct=100,rt=100,retry=10,unit=1):
data=(W*2000)(*values);status=(U*24)()
result=lib.ab_call(cmd,h,ip,port,unit,f,addr,q,data,2000,status,24,0,ct,rt,retry)
return result,list(status),list(data)
def until(fn,seconds=1):
end=time.monotonic()+seconds
while time.monotonic()<end:
v=fn()
if v:return v
time.sleep(.002)
raise AssertionError('condition timed out')
def configure(watchdog=2000,cpu=-1):
r,s,_=call(40,addr=cpu+1,ct=1,rt=watchdog);assert r==0,(r,s)
def client(port):
r,s,_=call(1,ip=0x7f000001,port=port);assert not r;h=s[0]
assert not call(9,h,rt=100)[0];assert not call(3,h)[0]
until(lambda:call(5,h)[1][1]==3)
return h
def recv_exact(peer,count):
result=b''
while len(result)<count:
v=peer.recv(count-len(result));assert v;result+=v
return result
def server():
r,s,_=call(30,ip=0x7f000001,port=port);assert not r;h=s[0]
for t in range(4):assert not call(35,h,f=t,q=8,values=[0]*8)[0]
assert not call(32,h)[0]
until(lambda:call(34,h)[1][1])
return h
probe=socket.socket();probe.bind(('127.0.0.1',0));port=probe.getsockname()[1];probe.close()
try:
configure();sh=server();ch=client(port)
assert not call(6,ch,q=2)[0]
# The reply deadline is 100 ms; the PLC deliberately does nothing for 250 ms.
time.sleep(.25)
r,s,d=call(7,ch);assert not r and s[3] and not s[4] and d[:2]==[0,0],s
assert call(6,ch)[0]==2 # Completion cannot be overwritten before acknowledgment.
r,m,_=call(12,ch);assert not r and m[21]<100 and m[22]>=100,m
assert call(10,ch)[0]==0
print('PASS worker completes TCP before timeout with PLC absent for 250 ms; result retained and timed')
# Remote write must survive a stale PLC push before the PLC pulls its result.
peer=socket.create_connection(('127.0.0.1',port));peer.settimeout(1)
pdu=struct.pack('>BHH',6,0,4321);peer.sendall(struct.pack('>HHHB',1,0,len(pdu)+1,1)+pdu)
assert recv_exact(peer,12)[7:]==pdu
assert call(35,sh,f=2,q=8,values=[0]*8)[0]==0
assert call(36,sh,f=2,q=8)[2][0]==4321
# After acknowledgment a deliberate application override is permitted.
call(35,sh,f=2,q=8,values=[42]*8);assert call(36,sh,f=2,q=8)[2][0]==42
assert call(35,sh,f=2,addr=1,q=8,values=[0]*8)[0]==1 # explicit recreate for remap
peer.close();call(2,ch);call(31,sh)
print('PASS autonomous TCP slave and lossless remote-write handoff')
# Real serial PTY, again no PLC ticks after submission.
master,slave=pty.openpty();path=os.ttyname(slave);os.close(slave)
r,s,_=call(20,ip=9600,unit=0,f=8,addr=1,q=len(path),ct=0,values=list(path.encode()));assert not r;h=s[0]
call(9,h,rt=100);call(3,h);until(lambda:call(5,h)[1][1]==3)
call(6,h,unit=1,f=3,q=1)
assert select.select([master],[],[],1)[0];request=os.read(master,256);assert len(request)==8
def crc(p):
c=65535
for b in p:
c^=b
for _ in range(8):c=(c>>1)^(0xa001 if c&1 else 0)
return c
p=bytes([1,3,2,0,77]);os.write(master,p+struct.pack('<H',crc(p)))
time.sleep(.25);r,s,d=call(7,h);assert not r and s[3] and d[0]==77,s
call(2,h);os.close(master)
print('PASS RTU completion while PLC does not scan for 250 ms')
# Watchdog cancels an in-flight write without replaying it on resume.
configure(100)
listener=socket.socket();listener.bind(('127.0.0.1',0));listener.listen();listener.settimeout(1)
h=client(listener.getsockname()[1]);peer,_=listener.accept();peer.settimeout(1)
call(11,h,rt=1000);assert not call(6,h,f=16,q=1,values=[9])[0]
packet=recv_exact(peer,15);assert packet[7]==16
time.sleep(.16)
# command 12 is read-only and does not renew the scan lease.
r,s,_=call(12,h);assert not r and s[1]==0 and s[5]==10 and s[9]==1,s
assert peer.recv(1)==b''
call(5,h);peer2,_=listener.accept();peer2.settimeout(.03)
try:assert not peer2.recv(1), 'write replayed'
except socket.timeout:pass
call(2,h);peer.close();peer2.close();listener.close()
print('PASS scan watchdog closes transport, preserves uncertain write, resumes without replay')
# Server closes its listener when scanning ceases and recovers on refresh.
configure(100);sh=server();time.sleep(.16)
probe=socket.socket();probe.settimeout(.1);assert probe.connect_ex(('127.0.0.1',port))!=0;probe.close()
call(34,sh);until(lambda:call(34,sh)[1][1]);call(31,sh)
print('PASS server watchdog and restart')
# CPU selection, failure reporting, and restoring inherited mask.
configure(cpu=min(os.sched_getaffinity(0)))
r,s,_=call(41);assert not r and s[3]==min(os.sched_getaffinity(0))+1
assert call(40,addr=65535,ct=1,rt=2000)[0]==1
configure()
failures=[]
def reader():
for _ in range(200):
if call(41)[0]:failures.append('snapshot failure')
threads=[threading.Thread(target=reader) for _ in range(4)]
for t in threads:t.start()
for t in threads:t.join()
assert not failures
print('PASS configurable affinity, invalid arguments, concurrent snapshot access')
for _ in range(20):
lib.ab_shutdown();assert call(41)[1][1]==0;configure();assert call(41)[1][1]==1
print('PASS repeated joined shutdown/restart')
finally:lib.ab_shutdown()