Files
MeshCore/test/test_reply_tx_integration.py
T

400 lines
18 KiB
Python

"""Keep OTA responses separate from the admin CLI reply policy."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
class ReplyTxIntegrationTest(unittest.TestCase):
def test_deferred_responses_keep_the_request_profile(self):
mesh_source = (ROOT / 'src/Mesh.cpp').read_text()
dispatcher = (ROOT / 'src/Dispatcher.cpp').read_text()
harness = r'''
#include <cassert>
#include <vector>
#include <RadioProfiles.h>
#include <Packet.h>
#include <helpers/ota/OtaManager.h>
#include <helpers/ota/OtaProtocol.h>
#include "mota_vectors.h"
namespace mesh {
namespace ota {
struct OtaContext { OtaManager& manager; };
static OtaContext* active;
OtaContext* ota_context_if_active() { return active; }
OtaContext& ota_ctx() { return *active; }
}
struct Manager {
int free=40;
int getFreeCount() { return free; }
};
static int queuedPacedOtaResponses(Manager*) { return 0; }
static constexpr int OTA_EGRESS_QUEUE_CREDIT=4, OTA_EGRESS_MIN_FREE=4;
@PACED@
struct Radio {
RadioProfiles config;
const RadioProfiles* profiles() const { return &config; }
};
struct Mesh {
Manager manager; Manager* _mgr=&manager;
Radio radio; Radio* _radio=&radio;
ota::OtaManager ota_manager;
ota::OtaContext context{ota_manager};
Packet packet;
std::vector<uint8_t> masks;
uint8_t receive_profile=0;
bool receiving=false;
Mesh() { ota::active=&context; }
bool isAnyTempRadioActive() { return true; }
Packet* createOtaPacket(const uint8_t*,uint16_t) {
packet=Packet(); packet.header=PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT;
packet.radio_profile=receive_profile; packet.radio_local=!receiving;
packet.radio_generation=receiving ? radio.config.generation[receive_profile] : 0;
return &packet;
}
void releasePacket(Packet*) {}
bool sendOtaFlood(Packet* p) { masks.push_back(getTransmitProfileMask(p)); return true; }
uint8_t getTransmitProfileMask(const Packet*) const;
static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool);
void service() { @SERVICE@ }
};
@MASK@
@ADAPTER@
}
int main() {
mesh::Mesh node;
node.radio.config.primary_temporary=true;
node.radio.config.secondary_temporary=true;
node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
node.radio.config.cross=mesh::RadioCrossMode::Off;
node.ota_manager.begin(0,node.otaSendAdapter,&node);
assert(node.ota_manager.serve(MOTA_VEC,sizeof(MOTA_VEC)));
mesh::ota::ReqMsg request{};
memcpy(request.manifest_id,EXP_MERKLE_ROOT,4);
request.block_idx=0; request.want_mask=1;
uint8_t wire[MAX_PACKET_PAYLOAD];
const auto length=mesh::ota::encode_req(wire,sizeof(wire),request);
node.receiving=true; node.receive_profile=1;
mesh::ota::OtaReplyRoute route;
route.profile=1; route.generation=node.radio.config.generation[1];
assert(node.ota_manager.on_message(wire,length,route));
assert(node.masks.empty()); // DATA is deliberately deferred until later
node.receiving=false; node.receive_profile=0;
node.manager.free=4; node.service(); // backpressure must retain origin
assert(node.ota_manager.pendingServeJobs()==1 && node.masks.empty());
node.manager.free=40;
node.service();
assert(node.masks.size()==1 && node.masks[0]==2);
assert(node.packet.radio_profile==1 && !node.packet.radio_local);
assert(node.packet.radio_generation==route.generation);
assert(node.ota_manager.replyRoute().profile==0xFF);
// Admin CLI reply-both/force must not change an OTA response's origin or
// enable its otherwise forbidden transmit profile.
node.ota_manager.clearPendingEgress(); node.masks.clear();
node.radio.config.reply_tx=mesh::RADIO_TX_BOTH;
node.radio.config.reply_force=true;
assert(node.ota_manager.on_message(wire,length,route));
node.service();
assert(node.masks.size()==1 && node.masks[0]==2);
assert(!node.packet.radio_reply);
node.radio.config.reply_tx=mesh::RADIO_TX_OFF;
node.radio.config.reply_force=false;
node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
// Identical block requests heard on separate radio domains must not merge.
node.ota_manager.clearPendingEgress(); node.masks.clear();
assert(node.ota_manager.on_message(wire,length,route));
auto primary=route; primary.profile=0;
assert(node.ota_manager.on_message(wire,length,primary));
assert(node.ota_manager.pendingServeJobs()==2);
node.service();
mesh::ota::ReqProofMsg proof{};
memcpy(proof.manifest_id,EXP_MERKLE_ROOT,4); proof.block_idx=0;
uint8_t proof_wire[MAX_PACKET_PAYLOAD];
const auto proof_len=mesh::ota::encode_req_proof(proof_wire,sizeof(proof_wire),proof);
assert(node.ota_manager.on_message(proof_wire,proof_len,primary));
assert(node.ota_manager.pendingServeJobs()==2); // primary proof merges only with primary DATA
assert(node.ota_manager.on_message(proof_wire,proof_len,route));
node.service(); // secondary proof
node.service(); // primary DATA
node.service(); // primary proof
assert(node.ota_manager.pendingServeJobs()==0);
assert((node.masks==std::vector<uint8_t>{2,2,1,1}));
// Reconfiguration retires the stale job even when it was waiting on proof.
node.masks.clear();
assert(node.ota_manager.on_message(wire,length,route));
node.service();
const auto sent=node.ota_manager.packetsSent();
++node.radio.config.generation[1];
auto replacement=route; ++replacement.generation;
assert(node.ota_manager.on_message(wire,length,replacement));
assert(node.ota_manager.pendingServeJobs()==2);
node.service();
assert(node.ota_manager.pendingServeJobs()==1);
assert(node.ota_manager.packetsSent()==sent); // discarded is not reported as sent
node.service();
assert(node.masks.size()==2 && node.packet.radio_generation==replacement.generation);
node.radio.config.secondary.mode=mesh::RadioProfileMode::Off;
node.service();
assert(node.ota_manager.pendingServeJobs()==0);
node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
// The paced manifest queue also separates origins and expires stale sessions.
node.masks.clear();
mesh::ota::GetManifestMsg manifest{};
memcpy(manifest.manifest_id,EXP_MERKLE_ROOT,4); manifest.want_mask=1;
uint8_t manifest_wire[MAX_PACKET_PAYLOAD];
const auto manifest_len=mesh::ota::encode_get_manifest(manifest_wire,sizeof(manifest_wire),manifest);
assert(node.ota_manager.on_message(manifest_wire,manifest_len,route));
assert(node.ota_manager.on_message(manifest_wire,manifest_len,replacement));
assert(node.ota_manager.on_message(manifest_wire,manifest_len,primary));
assert(node.ota_manager.pendingManifestJobs()==3);
node.service(); // stale route is discarded even before its pacing deadline
assert(node.ota_manager.pendingManifestJobs()==2 && node.masks.empty());
node.ota_manager.set_clock(1000000);
node.service(); node.service();
assert((node.masks==std::vector<uint8_t>{2,1}));
assert(node.ota_manager.pendingManifestJobs()==0);
// Changing primary also retires its old request before any fragment is sent.
node.masks.clear();
assert(node.ota_manager.on_message(wire,length,primary));
++node.radio.config.generation[0];
++primary.generation;
assert(node.ota_manager.on_message(wire,length,primary));
node.service();
assert(node.masks.empty() && node.ota_manager.pendingServeJobs()==1);
node.service();
assert(node.masks.size()==1 && node.masks[0]==1);
assert(node.packet.radio_generation==primary.generation);
node.ota_manager.clearPendingEgress();
// LEAVES is paced and retains its request scope after the receive stack
// unwinds. Backpressure must not consume the pending descriptor.
node.masks.clear();
mesh::ota::GetLeavesMsg leaves{};
memcpy(leaves.manifest_id,EXP_MERKLE_ROOT,4); leaves.want_mask=1;
uint8_t leaves_wire[MAX_PACKET_PAYLOAD];
const auto leaves_len=mesh::ota::encode_get_leaves(leaves_wire,sizeof(leaves_wire),leaves);
node.receiving=true; node.receive_profile=1;
assert(node.ota_manager.on_message(leaves_wire,leaves_len,replacement));
assert(node.masks.empty() && node.ota_manager.pendingManifestJobs()==1);
node.receiving=false; node.receive_profile=0;
node.manager.free=4;
node.ota_manager.set_clock(2000000);
node.service();
assert(node.masks.empty() && node.ota_manager.pendingManifestJobs()==1);
node.manager.free=40;
node.service();
assert(node.masks.size()==1 && node.masks[0]==2);
assert(!node.packet.radio_reply && !node.packet.radio_local);
assert(node.ota_manager.pendingManifestJobs()==0);
// Deferred affinity cannot bleed into a later local send.
const uint8_t data=mesh::ota::OTA_DATA;
assert(node.otaSendAdapter(&node,&data,1,false));
assert(node.masks.size()==2 && node.masks[1]==1 && node.packet.radio_local);
// A regular primary and temporary secondary stay isolated unless cross is on.
node.radio.config.primary_temporary=false;
node.masks.clear();
assert(node.ota_manager.on_message(wire,length,replacement));
node.service();
assert(node.masks.size()==1 && node.masks[0]==2);
node.ota_manager.clearPendingEgress();
node.radio.config.cross=mesh::RadioCrossMode::On;
assert(node.ota_manager.on_message(wire,length,replacement));
node.service();
assert(node.masks.size()==2 && node.masks[1]==3);
}
'''
harness = harness.replace('@PACED@', method(mesh_source, 'static bool isPacedOtaResponse('))
harness = harness.replace('@ADAPTER@', method(mesh_source, 'bool Mesh::otaSendAdapter('))
harness = harness.replace('@MASK@', method(dispatcher, 'uint8_t Dispatcher::getTransmitProfileMask(')
.replace('Dispatcher::', 'Mesh::'))
start = mesh_source.index('ota::ota_ctx().manager.serviceEgress(')
service = mesh_source[start:mesh_source.index('}, this);', start) + len('}, this);')]
harness = harness.replace('@SERVICE@', service)
self.assertIn('context->manager.on_message(pkt->payload, pkt->payload_len, route)', mesh_source)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)
source = path / 'routing.cpp'; source.write_text(harness)
binary = path / 'routing'
sources = ['OtaManager.cpp', 'OtaProtocol.cpp', 'MotaContainer.cpp', 'MerkleTree.cpp']
result = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-I', str(ROOT / 'src'), '-I', str(ROOT / 'test/mocks'),
'-I', str(ROOT / 'test/test_ota'), str(source),
*[str(ROOT / 'src/helpers/ota' / name) for name in sources],
str(ROOT / 'src/Utils.cpp'), str(ROOT / 'src/Packet.cpp'), '-o', str(binary)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_async_ota_adapter_excludes_admin_policy_and_retains_backpressure(self):
source = (ROOT / 'src/Mesh.cpp').read_text()
adapter = method(source, 'bool Mesh::otaSendAdapter(')
paced = method(source, 'static bool isPacedOtaResponse(')
harness = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <Packet.h>
#include <helpers/ota/OtaManager.h>
namespace mesh {
namespace ota {
struct Context {
struct Manager {
OtaReplyRoute route;
OtaReplyRoute replyRoute() const { return route; }
} manager;
};
static Context* active = nullptr;
Context* ota_context_if_active() { return active; }
}
struct Manager { int free=10; int getFreeCount() { return free; } };
static int queued;
static int queuedPacedOtaResponses(Manager*) { return queued; }
static constexpr int OTA_EGRESS_QUEUE_CREDIT=@CREDIT@, OTA_EGRESS_MIN_FREE=@RESERVE@;
@PACED@
struct Mesh {
Manager manager; Manager* _mgr=&manager;
Packet packet;
bool temporary=true, allocation=true, admit=true, was_reply=false;
uint8_t mask=1;
int allocations=0, sends=0;
bool isAnyTempRadioActive() { return temporary; }
Packet* createOtaPacket(const uint8_t*,uint16_t) {
++allocations;
// Deliberately opposite to expected for half the types: classification
// must not accidentally depend on the receive call stack or a prior send.
const bool previous_reply=packet.radio_reply;
packet=Packet();
packet.radio_reply=!previous_reply;
if (!allocation || !manager.free) return nullptr;
--manager.free;
return &packet;
}
uint8_t getTransmitProfileMask(const Packet*) { return mask; }
void releasePacket(Packet*) { ++manager.free; }
bool sendOtaFlood(Packet* p) { ++sends; was_reply=p->radio_reply; releasePacket(p); return admit; }
static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool);
};
@ADAPTER@
}
int main() {
mesh::Mesh node;
for (unsigned type=0;type<256;++type) {
const uint8_t msg=type;
assert(node.otaSendAdapter(&node,&msg,1,true));
assert(!node.was_reply);
}
const uint8_t data=mesh::ota::OTA_DATA, proof=mesh::ota::OTA_PROOF;
const uint8_t manifest=mesh::ota::OTA_MANIFEST, leaves=mesh::ota::OTA_LEAVES;
for (const uint8_t type : {data,proof,manifest,leaves}) {
for (uint8_t mask : {1,2,3}) {
for (int queued=0;queued<=mesh::OTA_EGRESS_QUEUE_CREDIT+1;++queued) {
for (int free=0;free<=mesh::OTA_EGRESS_MIN_FREE+3;++free) {
node.mask=mask; mesh::queued=queued; node.manager.free=free;
const int copies=mask==3 ? 2 : 1;
const bool allowed=queued+copies<=mesh::OTA_EGRESS_QUEUE_CREDIT
&& free>=mesh::OTA_EGRESS_MIN_FREE+copies;
const int allocations=node.allocations;
assert(node.otaSendAdapter(&node,&type,1,true)==allowed);
assert(node.manager.free==free); // failure releases, simulated successful TX drains
if (queued>=mesh::OTA_EGRESS_QUEUE_CREDIT || free<=mesh::OTA_EGRESS_MIN_FREE)
assert(node.allocations==allocations); // normal backpressure is not pool exhaustion
}
}
}
}
node.mask=0; node.manager.free=10; mesh::queued=0;
assert(!node.otaSendAdapter(&node,&data,1,true));
assert(node.manager.free==10);
node.mask=3;
int allocated=node.allocations;
node.manager.free=10; node.temporary=false;
assert(!node.otaSendAdapter(&node,&data,1,true));
assert(node.allocations==allocated);
node.temporary=true; node.allocation=false;
int sends=node.sends;
assert(!node.otaSendAdapter(&node,&data,1,true)); assert(node.sends==sends);
node.allocation=true; node.admit=false;
assert(!node.otaSendAdapter(&node,&data,1,true));
// Use the actual Packet and deferred-route definitions. A routed reply
// retains its origin and normal crossing policy. A negotiation
// fallback is not a reply and must stay bound to its original radio.
node.admit=true; node.mask=1;
mesh::ota::Context context;
mesh::ota::active=&context;
for (uint8_t profile : {0,1}) {
context.manager.route.generation=1234+profile;
context.manager.route.profile=profile;
for (uint8_t type : {mesh::ota::OTA_QUERY,mesh::ota::OTA_DATA}) {
assert(node.otaSendAdapter(&node,&type,1,false));
const bool reply=type==mesh::ota::OTA_DATA;
assert(!node.packet.radio_reply && !node.packet.radio_local);
assert(node.packet.radio_profile==profile && node.packet.radio_origin==profile);
assert(node.packet.radio_generation==1234+profile);
assert(node.packet.radio_origin_generation==1234+profile);
assert(node.packet.radio_bound==!reply);
assert(node.packet.tx_radio==(reply ? mesh::RADIO_TX_AUTO
: profile==0 ? mesh::RADIO_TX_PRIMARY : mesh::RADIO_TX_SECONDARY));
}
}
context.manager.route={};
assert(node.otaSendAdapter(&node,&data,1,false));
assert(node.packet.radio_local && !node.packet.radio_bound);
assert(node.packet.tx_radio==mesh::RADIO_TX_AUTO);
}
'''
with tempfile.TemporaryDirectory() as folder:
cpp = Path(folder) / 'reply.cpp'
exe = Path(folder) / 'reply'
header = (ROOT / 'src/Mesh.h').read_text()
credit = re.search(r'#define OTA_EGRESS_QUEUE_CREDIT (\d+)', header).group(1)
reserve = re.search(r'#define OTA_EGRESS_MIN_FREE (\d+)', header).group(1)
template = harness.replace('@PACED@', paced).replace('@CREDIT@', credit)
template = template.replace('@RESERVE@', reserve)
sanitizers = ([] if os.name == 'nt' else ['-fsanitize=address,undefined',
'-fno-sanitize-recover=all', '-fno-pie', '-no-pie'])
command = [os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', *sanitizers, '-I', str(ROOT / 'src'),
'-I', str(ROOT / 'test/mocks'), str(cpp), str(ROOT / 'src/Packet.cpp'),
'-o', str(exe)]
variants = [('production', adapter, None)]
for old, new, failure in (
('p->radio_bound = true;', 'p->radio_bound = false;',
'node.packet.radio_bound==!reply'),
('queued + copies > OTA_EGRESS_QUEUE_CREDIT', 'false',
'node.otaSendAdapter(&node,&type,1,true)==allowed'),
):
self.assertEqual(adapter.count(old), 1)
variants.append((old, adapter.replace(old, new), failure))
for label, candidate, failure in variants:
with self.subTest(adapter=label):
cpp.write_text(template.replace('@ADAPTER@', candidate), encoding='ascii')
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
checked = subprocess.run([str(exe)], capture_output=True, text=True, timeout=15)
if failure is None:
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
else:
self.assertNotEqual(checked.returncode, 0,
'negative control unexpectedly passed')
self.assertIn('Assertion', checked.stderr)
self.assertIn(failure, checked.stderr)
if __name__ == '__main__':
unittest.main()