fix(battery): keep native timer callbacks reload-safe
Tests / test (push) Failing after 53s

This commit is contained in:
dh
2026-10-08 05:15:27 +00:00
parent 5885681669
commit d42cfb3342
10 changed files with 535 additions and 6 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Bounded native fixture test on ubuntu only; never reload Enelix-EMS."""
import hashlib
import json
from pathlib import Path
import shutil
import socket
import sys
import time
import urllib.request
if socket.gethostname() != 'ubuntu':
raise RuntimeError('Development host only')
base = Path('/srv/agent/battery-timer-native-20261007')
folder = 'ENELIXTimerLifecycleProbe20261007'
target = Path('/srv/symcon/v8/dev/data/modules') / folder
module = '{044221CE-A8E5-4413-A081-C3235FF73350}'
library = '{748CA183-27CD-4EC2-8730-A8A0A2B2C953}'
mc = 34135
report = {'hardwareCommands':0,'reloadedLibrary':folder,'phases':[],
'scope':'Six explicitly authorized isolated reload tests, 2026-10-08',
'registrationReloads':0, 'testReloads':0, 'startedAt':time.time()}
mode = 'wrapper'
report['mode'] = mode
cycles = 3
report['cycles'] = cycles
report_path = base / ('report-six-reloads-20261008-' + str(int(time.time())) + '.json')
instance = None
def rpc(method, params, timeout=20):
payload=json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
req=urllib.request.Request('http://127.0.0.1:3777/api/',payload,{'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=timeout) as r:
result=json.load(r)
if 'error' in result: raise RuntimeError(str(result['error']))
return result['result']
def snapshot():
ts=[]
for tid in rpc('IPS_GetTimerList',[]):
t=rpc('IPS_GetTimer',[tid])
if t.get('InstanceID')==instance: ts.append(t)
return {'at':time.time(),'timers':ts,'state':json.loads(rpc('ENLTMR_ReadProbeState',[instance]))}
def wait_running(name):
end=time.monotonic()+18
while time.monotonic()<end:
snap=snapshot()
if any(t['Name']==name and t['Running'] for t in snap['timers']): return snap
time.sleep(.15)
raise RuntimeError('No running timer: '+name)
report['kernel']=rpc('IPS_GetKernelVersion',[])
if report['kernel']!='8.0': raise RuntimeError('Symcon 8.0 required')
report['kernelDate']=rpc('IPS_GetKernelDate',[])
if rpc('IPS_GetInstance',[mc])['ModuleInfo']['ModuleID'] != '{B8A5067A-AFC2-3798-FEDC-BCD02A45615E}':
raise RuntimeError('Module Control identity changed')
if rpc('IPS_ModuleExists',[module]) or target.exists(): raise RuntimeError('Existing fixture; refusing overwrite')
report['beforeInstances']=rpc('IPS_GetInstanceList',[])
report['beforeConfigurationHashes']={str(i):hashlib.sha256(rpc('IPS_GetConfiguration',[i]).encode()).hexdigest()
for i in report['beforeInstances']}
report['beforeTimers']=[rpc('IPS_GetTimer',[i]) for i in rpc('IPS_GetTimerList',[])]
report['preexistingProbeTimers'] = [t for t in report['beforeTimers'] if t['InstanceID'] in [32751,34162,50935]]
if report['preexistingProbeTimers']: raise RuntimeError('Uncleared previous fixture timers')
if len(report['beforeTimers']) > 55:
raise RuntimeError('Timer growth exceeds bounded test budget')
expected_fixture = {
'library.json':'d856277670e083d8a2616ad5f9994f6cb109fd887c47be2e5d152316eddb4067',
'TimerLifecycleProbe/module.json':'17a886572a8164a909ecfc3075cada9af2d510d06fbba012ff597c247b732161',
'TimerLifecycleProbe/module.php':'c9b4dfc861ad29917052e2fdef44b13e90f219f954b14e3d952de7de0f232742',
}
found_fixture = {str(p.relative_to(base/'fixture')):hashlib.sha256(p.read_bytes()).hexdigest()
for p in (base/'fixture').rglob('*') if p.is_file()}
if found_fixture != expected_fixture: raise RuntimeError('Unreviewed fixture changes; aborting')
report['originalFixtureHashes'] = found_fixture
shutil.copytree(base/'fixture',target)
if mode == 'wrapper':
file = target/'TimerLifecycleProbe/module.php'
source = file.read_text().replace("IPS_RequestAction(\\$_IPS['TARGET'], 'Report', 0);", "ENLTMR_Tick(\\$_IPS['TARGET'], 'Report');")
source = source.replace("IPS_RequestAction(\\$_IPS['TARGET'], 'Read', 0);", "ENLTMR_Tick(\\$_IPS['TARGET'], 'Read');")
file.write_text(source)
report['fixtureSourceSha256'] = hashlib.sha256((target/'TimerLifecycleProbe/module.php').read_bytes()).hexdigest()
try:
rpc('MC_ReloadModule',[mc,folder])
report['registrationReloads'] += 1
if not rpc('IPS_ModuleExists',[module]): raise RuntimeError('Fixture registration failed')
instance=rpc('IPS_CreateInstance',[module])
report['instance']=instance
rpc('IPS_SetName',[instance,'Temporary ENELIX timer lifecycle probe'])
rpc('IPS_SetProperty',[instance,'LifecycleMode',mode])
rpc('IPS_ApplyChanges',[instance])
phases = [('reload','Rueckmeldesampler'),('reload','Meldezyklus')] * cycles
for action, timer in phases:
before = wait_running(timer)
if mode == 'idle':
end = time.monotonic() + 15
while any(t['Running'] for t in before['timers']):
if time.monotonic() > end: raise RuntimeError('No idle window')
time.sleep(.05)
before = snapshot()
phase={'action':action,'during':timer,'before':before}
start=time.monotonic()
try:
report['testReloads'] += 1
phase['result']=rpc('MC_ReloadModule',[mc,folder])
except RuntimeError as e:
phase['error']=str(e)
phase['durationSeconds']=time.monotonic()-start
phase['after']=[]
for _ in range(5):
time.sleep(2)
phase['after'].append(snapshot())
report['phases'].append(phase)
report_path.write_text(json.dumps(report,indent=2))
print(json.dumps({'phase':action,'during':timer,'duration':phase['durationSeconds'],
'error':phase.get('error'),'first':phase['after'][0]['state'],
'last':phase['after'][-1]['state'], 'timers':phase['after'][-1]['timers']}),flush=True)
states=phase['after']
if states[-1]['state']==states[0]['state']:
report['stalled']=True
break
except Exception as e:
report['error']=type(e).__name__+': '+str(e)
raise
finally:
if instance is not None:
if rpc('IPS_GetInstance',[instance])['ModuleInfo']['ModuleID']!=module:
raise RuntimeError('Fixture identity changed; refusing cleanup')
rpc('ENLTMR_StopProbe',[instance])
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if not any(t['Running'] for t in snapshot()['timers']): break
time.sleep(.1)
rpc('IPS_DeleteInstance',[instance])
try:
rpc('MC_DeleteModule',[mc,folder])
except Exception as e:
report['cleanupError']=str(e)
report['remainingFixtureInstances']=rpc('IPS_GetInstanceListByModuleID',[module]) if rpc('IPS_ModuleExists',[module]) else []
report['afterInstances']=rpc('IPS_GetInstanceList',[])
report['afterConfigurationHashes']={str(i):hashlib.sha256(rpc('IPS_GetConfiguration',[i]).encode()).hexdigest()
for i in report['afterInstances']}
report['originalConfigurationsPreserved'] = report['beforeConfigurationHashes'] == report['afterConfigurationHashes']
report['afterTimers']=[rpc('IPS_GetTimer',[i]) for i in rpc('IPS_GetTimerList',[])]
report['orphanFixtureTimers']=[t for t in report['afterTimers'] if t['InstanceID'] == instance]
report['originalInstancesPreserved']=set(report['beforeInstances'])==set(report['afterInstances'])
report['finishedAt'] = time.time()
report_path.write_text(json.dumps(report,indent=2))
print(json.dumps({'finished':True,'instance':instance,'cleanupError':report.get('cleanupError'),
'originalInstancesPreserved':report['originalInstancesPreserved'],
'orphanFixtureTimers':report['orphanFixtureTimers'],'report':str(report_path)}),flush=True)
if mode == 'wrapper':
recovered = len(report['phases']) == 2 * cycles and report['testReloads'] == 6
for phase in report['phases']:
first, last = phase['after'][0], phase['after'][-1]
recovered = recovered and all(last['state'][key] > first['state'][key]
for key in ['readRuns', 'reportRuns', 'scriptEnds'])
recovered = recovered and len(last['timers']) == 2 and all(t['LastRun'] > 0 for t in last['timers'])
if phase['action'] == 'reload':
recovered = recovered and 'error' not in phase
recovered = (recovered and report['originalInstancesPreserved'] and report['originalConfigurationsPreserved']
and not report['orphanFixtureTimers'] and not report.get('cleanupError'))
report['nativeTimerRecoveryPassed'] = bool(recovered)
report['applyBusyErrors'] = [p['error'] for p in report['phases'] if p['action'] == 'apply' and 'error' in p]
report_path.write_text(json.dumps(report, indent=2))
print(json.dumps({'nativeTimerRecoveryPassed':report['nativeTimerRecoveryPassed'],
'testReloads':report['testReloads'],
'originalConfigurationsPreserved':report['originalConfigurationsPreserved'],
'report':str(report_path)}),flush=True)
# Busy ApplyChanges is reported separately, never counted as a successful initialization.
raise SystemExit(0 if recovered else 1)