Preserve Wi-Fi reconnect backlogs by client IP
Retain same-IP replies and park one displaced IP backlog for 30 seconds using existing fixed buffers. Cancel old route-owned operations on takeover, preserve partial TCP frames, and cover reconnect isolation and queue limits with regression tests.
This commit is contained in:
1 parent
8ad8eeae88
commit
56f0835d75
12 files changed
+849
-14
No files matched your search
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
extern uint32_t mock_millis;
|
||||
inline unsigned long millis() { return mock_millis; }
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
class IPAddress {
|
||||
uint32_t _address = 0;
|
||||
public:
|
||||
IPAddress() = default;
|
||||
IPAddress(uint32_t address) : _address(address) {}
|
||||
IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d)
|
||||
: _address(uint32_t(a) << 24 | uint32_t(b) << 16 | uint32_t(c) << 8 | d) {}
|
||||
operator uint32_t() const { return _address; }
|
||||
bool operator==(const IPAddress& other) const { return _address == other._address; }
|
||||
bool operator!=(const IPAddress& other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
struct MockSocket {
|
||||
IPAddress address;
|
||||
bool connected = true;
|
||||
size_t write_limit = 0;
|
||||
size_t read_count = 0;
|
||||
std::vector<uint8_t> sent;
|
||||
std::deque<uint8_t> received;
|
||||
explicit MockSocket(IPAddress value) : address(value) {}
|
||||
};
|
||||
|
||||
class WiFiClient {
|
||||
std::shared_ptr<MockSocket> _socket;
|
||||
public:
|
||||
WiFiClient() = default;
|
||||
explicit WiFiClient(std::shared_ptr<MockSocket> socket) : _socket(socket) {}
|
||||
explicit operator bool() const { return _socket && _socket->connected; }
|
||||
bool connected() const { return _socket && _socket->connected; }
|
||||
void stop() { if (_socket) _socket->connected = false; }
|
||||
IPAddress remoteIP() const { return _socket ? _socket->address : IPAddress(); }
|
||||
int available() const { return connected() ? int(_socket->received.size()) : 0; }
|
||||
size_t read(uint8_t* dest, size_t size) {
|
||||
size_t read = 0;
|
||||
while (connected() && read < size && !_socket->received.empty()) {
|
||||
dest[read++] = _socket->received.front();
|
||||
_socket->received.pop_front();
|
||||
++_socket->read_count;
|
||||
}
|
||||
return read;
|
||||
}
|
||||
size_t readBytes(uint8_t* dest, size_t size) { return read(dest, size); }
|
||||
size_t write(const uint8_t* src, size_t size) {
|
||||
if (!connected()) return 0;
|
||||
const size_t written = std::min(size, _socket->write_limit);
|
||||
_socket->sent.insert(_socket->sent.end(), src, src + written);
|
||||
return written;
|
||||
}
|
||||
};
|
||||
|
||||
class WiFiServer {
|
||||
public:
|
||||
static std::deque<WiFiClient> incoming;
|
||||
void begin(int) {}
|
||||
void end() { incoming.clear(); }
|
||||
WiFiClient available() {
|
||||
if (incoming.empty()) return WiFiClient();
|
||||
WiFiClient client = incoming.front();
|
||||
incoming.pop_front();
|
||||
return client;
|
||||
}
|
||||
};
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
uint32_t mock_millis = 0;
|
||||
std::deque<WiFiClient> WiFiServer::incoming;
|
||||
|
||||
using Bytes = std::vector<uint8_t>;
|
||||
using Socket = std::shared_ptr<MockSocket>;
|
||||
|
||||
static const IPAddress A(192, 168, 1, 20);
|
||||
static const IPAddress B(192, 168, 1, 30);
|
||||
static const IPAddress C(192, 168, 1, 40);
|
||||
|
||||
static Bytes framed(std::initializer_list<Bytes> frames) {
|
||||
Bytes result;
|
||||
for (const auto& frame : frames) {
|
||||
result.push_back('>');
|
||||
result.push_back(uint8_t(frame.size()));
|
||||
result.push_back(uint8_t(frame.size() >> 8));
|
||||
result.insert(result.end(), frame.begin(), frame.end());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
SerialWifiInterface transport;
|
||||
uint8_t input[MAX_FRAME_SIZE] = {};
|
||||
int callbacks = 0;
|
||||
bool pending_operation = false;
|
||||
Socket next_client;
|
||||
|
||||
Fixture() {
|
||||
mock_millis = 100;
|
||||
WiFiServer::incoming.clear();
|
||||
transport.begin(5000);
|
||||
transport.enable();
|
||||
transport.setSessionChangedCallback([](void* context) {
|
||||
Fixture& fixture = *static_cast<Fixture*>(context);
|
||||
++fixture.callbacks;
|
||||
fixture.pending_operation = false;
|
||||
// Cancellation must happen before a new socket sees an old response or
|
||||
// has any of its own command bytes consumed.
|
||||
if (fixture.next_client) {
|
||||
assert(fixture.next_client->sent.empty());
|
||||
assert(fixture.next_client->read_count == 0);
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
~Fixture() { transport.end(); }
|
||||
|
||||
size_t tick() { return transport.checkRecvFrame(input); }
|
||||
|
||||
Socket connect(IPAddress address, const Bytes& command = {}) {
|
||||
Socket socket = std::make_shared<MockSocket>(address);
|
||||
socket->received.insert(socket->received.end(), command.begin(), command.end());
|
||||
next_client = socket;
|
||||
WiFiServer::incoming.push_back(WiFiClient(socket));
|
||||
tick();
|
||||
next_client.reset();
|
||||
assert(transport.isConnected());
|
||||
return socket;
|
||||
}
|
||||
|
||||
void enqueue(const Bytes& frame) {
|
||||
assert(transport.writeFrame(frame.data(), frame.size()) == frame.size());
|
||||
}
|
||||
|
||||
void drain(const Socket& socket) {
|
||||
socket->write_limit = std::numeric_limits<size_t>::max();
|
||||
for (int i = 0; i < 12; ++i) tick();
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const int scenario = std::atoi(argv[1]);
|
||||
Fixture f;
|
||||
const Bytes reply_a{0x01, 0x21, 0x22, 0x23};
|
||||
const Bytes reply_b{0x02, 0x31, 0x32};
|
||||
const Bytes reply_c{0x03, 0x41};
|
||||
|
||||
if (scenario == 0) {
|
||||
auto old = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
auto replacement = f.connect(A);
|
||||
assert(!old->connected && f.callbacks == 0);
|
||||
f.drain(replacement);
|
||||
assert(replacement->sent == framed({reply_a}));
|
||||
} else if (scenario == 1) {
|
||||
auto old = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
old->connected = false;
|
||||
f.tick();
|
||||
assert(!f.transport.isConnected());
|
||||
mock_millis += 60000; // Same-IP retention is not the different-IP grace timer.
|
||||
auto replacement = f.connect(A);
|
||||
f.drain(replacement);
|
||||
assert(replacement->sent == framed({reply_a}) && f.callbacks == 0);
|
||||
} else if (scenario == 2) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
auto other = f.connect(B);
|
||||
f.enqueue(reply_b);
|
||||
f.drain(other);
|
||||
assert(other->sent == framed({reply_b}) && f.callbacks == 1);
|
||||
} else if (scenario == 3) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
auto other = f.connect(B);
|
||||
f.enqueue(reply_b);
|
||||
mock_millis += 29999;
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == framed({reply_a}));
|
||||
assert(other->sent.empty());
|
||||
auto other_returned = f.connect(B);
|
||||
f.drain(other_returned);
|
||||
assert(other_returned->sent == framed({reply_b}));
|
||||
assert(f.callbacks == 3);
|
||||
} else if (scenario == 4) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
mock_millis += 30000;
|
||||
auto expired = f.connect(A);
|
||||
f.drain(expired);
|
||||
assert(expired->sent.empty());
|
||||
} else if (scenario == 5) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
mock_millis += 20000;
|
||||
f.connect(B); // This must not extend A's held deadline.
|
||||
assert(f.callbacks == 1);
|
||||
mock_millis += 10000;
|
||||
auto expired = f.connect(A);
|
||||
f.drain(expired);
|
||||
assert(expired->sent.empty());
|
||||
} else if (scenario == 6 || scenario == 7) {
|
||||
mock_millis = UINT32_MAX - 10000;
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
mock_millis += scenario == 6 ? 29999 : 30000;
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == (scenario == 6 ? framed({reply_a}) : Bytes{}));
|
||||
} else if (scenario == 8) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
f.enqueue(reply_b);
|
||||
f.connect(C); // Park B and evict A: only one spare fixed-size queue exists.
|
||||
f.enqueue(reply_c);
|
||||
auto evicted = f.connect(A);
|
||||
f.drain(evicted);
|
||||
assert(evicted->sent.empty());
|
||||
auto newest = f.connect(C);
|
||||
f.drain(newest);
|
||||
assert(newest->sent == framed({reply_c}));
|
||||
} else if (scenario == 9) {
|
||||
auto socket = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
socket->write_limit = 2;
|
||||
for (int i = 0; i < 8; ++i) f.tick();
|
||||
assert(socket->sent == framed({reply_a}));
|
||||
assert(!f.transport.hasPendingIO());
|
||||
} else if (scenario == 10) {
|
||||
auto socket = f.connect(A);
|
||||
const Bytes push{0x80, 0xA1, 0xA2, 0xA3};
|
||||
f.enqueue(push);
|
||||
socket->write_limit = 2;
|
||||
f.tick();
|
||||
assert(socket->sent.size() == 2);
|
||||
f.enqueue(reply_a); // Responses have higher queue priority than this push.
|
||||
f.drain(socket);
|
||||
assert(socket->sent == framed({push, reply_a}));
|
||||
} else if (scenario == 11) {
|
||||
auto old = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
old->write_limit = 5; // Header and part of payload already sent.
|
||||
f.tick();
|
||||
assert(old->sent.size() == 5);
|
||||
auto replacement = f.connect(A);
|
||||
f.drain(replacement);
|
||||
assert(replacement->sent == framed({reply_a}));
|
||||
} else if (scenario == 12) {
|
||||
auto old = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
old->write_limit = 2; // Partial header must not be a suffix on reconnection.
|
||||
f.tick();
|
||||
auto other = f.connect(B);
|
||||
f.drain(other);
|
||||
assert(other->sent.empty());
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == framed({reply_a}));
|
||||
} else if (scenario == 13 || scenario == 14) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
f.enqueue(reply_b);
|
||||
if (scenario == 13) f.transport.disable();
|
||||
else f.transport.end();
|
||||
assert(!f.transport.isConnected() && !f.transport.hasPendingIO());
|
||||
f.transport.begin(5000);
|
||||
f.transport.enable();
|
||||
auto previous = f.connect(A);
|
||||
f.drain(previous);
|
||||
assert(previous->sent.empty());
|
||||
auto other = f.connect(B);
|
||||
f.drain(other);
|
||||
assert(other->sent.empty());
|
||||
} else if (scenario == 15) {
|
||||
auto old = f.connect(A, Bytes{'<', 4, 0, 0x11}); // Incomplete old command.
|
||||
assert(old->read_count == 3);
|
||||
f.pending_operation = true;
|
||||
f.enqueue(reply_a);
|
||||
auto other = f.connect(B, Bytes{'<', 2, 0, 0x71, 0x72});
|
||||
assert(f.callbacks == 1 && !f.pending_operation);
|
||||
// The new header is parsed from its start, not attached to the old header.
|
||||
if (other->read_count == 0) assert(f.tick() == 2);
|
||||
assert(other->read_count == 5);
|
||||
assert(f.input[0] == 0x71 && f.input[1] == 0x72);
|
||||
assert(other->sent.empty());
|
||||
} else if (scenario == 16) {
|
||||
auto old = f.connect(A, Bytes{'<', 4, 0, 0x11});
|
||||
assert(old->read_count == 3);
|
||||
f.pending_operation = true;
|
||||
auto replacement = f.connect(A, Bytes{'<', 2, 0, 0x61, 0x62});
|
||||
assert(f.callbacks == 0 && f.pending_operation);
|
||||
assert(replacement->read_count == 5);
|
||||
assert(f.input[0] == 0x61 && f.input[1] == 0x62);
|
||||
} else if (scenario == 17) {
|
||||
auto socket = f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.tick(); // A zero-byte socket write is backpressure, not frame completion.
|
||||
assert(socket->sent.empty() && f.transport.hasPendingIO());
|
||||
f.drain(socket);
|
||||
assert(socket->sent == framed({reply_a}));
|
||||
} else if (scenario == 18) {
|
||||
f.connect(A);
|
||||
for (int i = 0; i < 4; ++i) f.enqueue(Bytes{0x01, uint8_t(i)});
|
||||
assert(f.transport.writeFrame(reply_a.data(), reply_a.size()) == 0);
|
||||
f.connect(B);
|
||||
for (int i = 0; i < 4; ++i) f.enqueue(Bytes{0x02, uint8_t(i)});
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == framed({{0x01, 0}, {0x01, 1}, {0x01, 2}, {0x01, 3}}));
|
||||
auto other = f.connect(B);
|
||||
f.drain(other);
|
||||
assert(other->sent == framed({{0x02, 0}, {0x02, 1}, {0x02, 2}, {0x02, 3}}));
|
||||
} else if (scenario == 19) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
mock_millis += 20000;
|
||||
f.connect(C); // B has no backlog: preserve A without extending its deadline.
|
||||
mock_millis += 9999;
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == framed({reply_a}));
|
||||
} else if (scenario == 20) {
|
||||
f.connect(A);
|
||||
f.enqueue(reply_a);
|
||||
f.connect(B);
|
||||
mock_millis += 20000;
|
||||
f.connect(C);
|
||||
assert(!f.transport.hasPendingIO()); // Parked-only data must not busy-loop.
|
||||
mock_millis += 10000;
|
||||
f.transport.loop();
|
||||
auto expired = f.connect(A);
|
||||
f.drain(expired);
|
||||
assert(expired->sent.empty());
|
||||
} else if (scenario == 21) {
|
||||
auto socket = f.connect(A);
|
||||
const Bytes push{0x80, 0xA1, 0xA2, 0xA3};
|
||||
const Bytes keep{0x81, 0xB1};
|
||||
const Bytes evict{0x88, 0xC1};
|
||||
f.enqueue(push);
|
||||
socket->write_limit = 2;
|
||||
f.tick();
|
||||
f.enqueue(keep);
|
||||
f.enqueue(evict);
|
||||
f.enqueue(reply_a);
|
||||
f.enqueue(reply_b); // Full queue can evict a waiting push, never its sent head.
|
||||
f.drain(socket);
|
||||
assert(socket->sent == framed({push, reply_a, reply_b, keep}));
|
||||
} else if (scenario == 22) {
|
||||
f.connect(A, Bytes{'<', 4, 0, 0x11});
|
||||
f.pending_operation = true;
|
||||
f.transport.disable();
|
||||
assert(f.callbacks == 1 && !f.pending_operation);
|
||||
f.transport.end(); // Repeated teardown must not cancel the same owner twice.
|
||||
assert(f.callbacks == 1);
|
||||
f.transport.begin(5000);
|
||||
f.transport.enable();
|
||||
auto replacement = f.connect(A, Bytes{'<', 2, 0, 0x61, 0x62});
|
||||
assert(replacement->read_count == 5);
|
||||
assert(f.input[0] == 0x61 && f.input[1] == 0x62);
|
||||
} else if (scenario == 23) {
|
||||
auto socket = f.connect(A);
|
||||
Bytes maximum(MAX_FRAME_SIZE);
|
||||
for (size_t i = 0; i < maximum.size(); ++i) maximum[i] = uint8_t(i);
|
||||
maximum[0] = 0x80; // Low-priority push, pinned once any bytes are sent.
|
||||
f.enqueue(maximum);
|
||||
socket->write_limit = MAX_FRAME_SIZE + 2; // All but the final payload byte.
|
||||
f.tick();
|
||||
assert(socket->sent.size() == MAX_FRAME_SIZE + 2);
|
||||
assert(f.transport.hasPendingIO());
|
||||
f.enqueue(reply_a);
|
||||
f.drain(socket);
|
||||
assert(socket->sent == framed({maximum, reply_a}));
|
||||
assert(!f.transport.hasPendingIO());
|
||||
} else if (scenario == 24) {
|
||||
auto old = f.connect(A);
|
||||
Bytes maximum(MAX_FRAME_SIZE);
|
||||
for (size_t i = 0; i < maximum.size(); ++i) maximum[i] = uint8_t(i ^ 0x55);
|
||||
maximum[0] = 0x01;
|
||||
f.enqueue(maximum);
|
||||
old->write_limit = MAX_FRAME_SIZE + 2;
|
||||
f.tick();
|
||||
assert(old->sent.size() == MAX_FRAME_SIZE + 2);
|
||||
f.connect(B);
|
||||
Bytes other_maximum(MAX_FRAME_SIZE, 0xFE);
|
||||
other_maximum[0] = 0x02;
|
||||
f.enqueue(other_maximum);
|
||||
auto returned = f.connect(A);
|
||||
f.drain(returned);
|
||||
assert(returned->sent == framed({maximum}));
|
||||
auto other_returned = f.connect(B);
|
||||
f.drain(other_returned);
|
||||
assert(other_returned->sent == framed({other_maximum}));
|
||||
} else {
|
||||
assert(false && "unknown scenario");
|
||||
}
|
||||
std::printf("PASS: Wi-Fi session scenario %d\n", scenario);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Exercise WiFi session cancellation against production route ownership code."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAIN = ROOT / "examples/companion_radio/main.cpp"
|
||||
MESH = ROOT / "examples/companion_radio/MyMesh.cpp"
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
|
||||
struct FakeInterface : BaseSerialInterface {
|
||||
bool enabled = false;
|
||||
bool connected = true;
|
||||
std::deque<std::vector<uint8_t>> input;
|
||||
void enable() override { enabled = true; }
|
||||
void disable() override { enabled = false; }
|
||||
bool isEnabled() const override { return enabled; }
|
||||
bool isConnected() const override { return connected; }
|
||||
bool isReadBusy() const override { return false; }
|
||||
bool isWriteBusy() const override { return false; }
|
||||
size_t writeFrame(const uint8_t[], size_t len) override { return len; }
|
||||
size_t checkRecvFrame(uint8_t dest[]) override {
|
||||
if (input.empty()) return 0;
|
||||
auto frame = input.front(); input.pop_front();
|
||||
memcpy(dest, frame.data(), frame.size());
|
||||
return frame.size();
|
||||
}
|
||||
} wifi_interface, usb_interface, bluetooth_interface;
|
||||
MultiSerialInterface interface_manager;
|
||||
|
||||
constexpr int EXPECTED_ACK_TABLE_SIZE = 6;
|
||||
struct MyMesh {
|
||||
bool streaming = false;
|
||||
int stream_cancels = 0, pending_cancels = 0, radio_cancels = 0;
|
||||
int trace_cancels = 0, signing_cancels = 0, expirations = 0;
|
||||
BaseSerialInterface* pending_serial_reply_route = nullptr;
|
||||
BaseSerialInterface* command_radio_reply_route = nullptr;
|
||||
BaseSerialInterface* binary_trace_reply_route = nullptr;
|
||||
BaseSerialInterface* sign_data_reply_route = nullptr;
|
||||
struct Ack { BaseSerialInterface* reply_route; bool radio_retry; };
|
||||
Ack expected_ack_table[EXPECTED_ACK_TABLE_SIZE] = {};
|
||||
void cancelSerialResponseStream() {
|
||||
++stream_cancels;
|
||||
if (streaming) { streaming = false; interface_manager.unlockReplyRoute(); }
|
||||
}
|
||||
void clearPendingReqs() { ++pending_cancels; pending_serial_reply_route = nullptr; }
|
||||
void cancelPendingRadioParamApply() { ++radio_cancels; command_radio_reply_route = nullptr; }
|
||||
void clearBinaryTraceReply() { ++trace_cancels; binary_trace_reply_route = nullptr; }
|
||||
void cancelSigningSession() { ++signing_cancels; sign_data_reply_route = nullptr; }
|
||||
void expireExpectedAcks() { ++expirations; }
|
||||
void cancelSerialOperationsForRoute(BaseSerialInterface*);
|
||||
} the_mesh;
|
||||
|
||||
@CANCEL_OPERATIONS@
|
||||
@CANCEL_SESSION@
|
||||
|
||||
int main() {
|
||||
assert(interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface));
|
||||
assert(interface_manager.addInterface(InterfaceType::USB, &usb_interface));
|
||||
assert(interface_manager.addInterface(InterfaceType::Bluetooth, &bluetooth_interface));
|
||||
FakeInterface* routes[] = {&wifi_interface, &usb_interface, &bluetooth_interface};
|
||||
for (auto owner : routes) {
|
||||
interface_manager.enable();
|
||||
the_mesh = MyMesh();
|
||||
owner->input.push_back({0x04});
|
||||
uint8_t command[MAX_FRAME_SIZE] = {};
|
||||
assert(interface_manager.checkRecvFrame(command) == 1);
|
||||
interface_manager.lockReplyRoute();
|
||||
the_mesh.streaming = true;
|
||||
the_mesh.pending_serial_reply_route = owner;
|
||||
the_mesh.command_radio_reply_route = owner;
|
||||
the_mesh.binary_trace_reply_route = owner;
|
||||
the_mesh.sign_data_reply_route = owner;
|
||||
for (unsigned i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
|
||||
the_mesh.expected_ack_table[i] = {routes[i % 3], true};
|
||||
}
|
||||
|
||||
// The transport marks itself disconnected during the synchronous callback.
|
||||
wifi_interface.connected = false;
|
||||
cancelCompanionWiFiSession(nullptr);
|
||||
wifi_interface.connected = true;
|
||||
const bool cancelled = owner == &wifi_interface;
|
||||
assert(the_mesh.stream_cancels == int(cancelled));
|
||||
assert(the_mesh.streaming == !cancelled);
|
||||
assert(the_mesh.pending_cancels == int(cancelled));
|
||||
assert(the_mesh.radio_cancels == int(cancelled));
|
||||
assert(the_mesh.trace_cancels == int(cancelled));
|
||||
assert(the_mesh.signing_cancels == int(cancelled));
|
||||
assert(the_mesh.pending_serial_reply_route == (cancelled ? nullptr : owner));
|
||||
assert(the_mesh.command_radio_reply_route == (cancelled ? nullptr : owner));
|
||||
assert(the_mesh.binary_trace_reply_route == (cancelled ? nullptr : owner));
|
||||
assert(the_mesh.sign_data_reply_route == (cancelled ? nullptr : owner));
|
||||
assert(!interface_manager.isReplyRouteFor(&wifi_interface));
|
||||
if (!cancelled) assert(interface_manager.isReplyRouteFor(owner));
|
||||
for (unsigned i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
|
||||
// Detach WiFi's notification, not the radio's accepted message/retry.
|
||||
assert(the_mesh.expected_ack_table[i].reply_route ==
|
||||
(i % 3 == 0 ? nullptr : routes[i % 3]));
|
||||
assert(the_mesh.expected_ack_table[i].radio_retry);
|
||||
}
|
||||
assert(the_mesh.expirations == 1);
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CompanionWiFiSessionTest(unittest.TestCase):
|
||||
def test_callback_is_registered_before_wifi_interface_can_start(self):
|
||||
source = MAIN.read_text(encoding="utf-8")
|
||||
registration = source.index(
|
||||
"wifi_interface.setSessionChangedCallback(cancelCompanionWiFiSession, nullptr)"
|
||||
)
|
||||
setup = source.index("void setup()")
|
||||
add = source.index("interface_manager.addInterface(InterfaceType::WiFi", setup)
|
||||
start = source.index("startCompanionWiFi();", add)
|
||||
self.assertLess(registration, add)
|
||||
self.assertLess(add, start)
|
||||
|
||||
def test_session_reset_cancels_only_wifi_owned_work(self):
|
||||
main = MAIN.read_text(encoding="utf-8")
|
||||
mesh = MESH.read_text(encoding="utf-8")
|
||||
callback = extract_braced(main, "static void cancelCompanionWiFiSession(")
|
||||
operations = extract_braced(mesh, "void MyMesh::cancelSerialOperationsForRoute(")
|
||||
source = HARNESS.replace("@CANCEL_OPERATIONS@", operations).replace(
|
||||
"@CANCEL_SESSION@", callback
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
executable = Path(directory) / ("wifi_session.exe" if os.name == "nt" else "wifi_session")
|
||||
result = subprocess.run(
|
||||
[os.environ.get("CXX", "g++"), "-std=c++17", "-Werror",
|
||||
f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}",
|
||||
"-x", "c++", "-", "-o", str(executable)],
|
||||
input=source, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(executable)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -448,6 +448,54 @@ TEST(MultiSerialInterface, ForgetsOnlyTheDisconnectedHostReplyRoute) {
|
||||
EXPECT_TRUE(manager.isReplyRouteFor(&bluetooth));
|
||||
}
|
||||
|
||||
TEST(MultiSerialInterface, SessionChangeDuringLockedReadKeepsNewFrameOnItsTransport) {
|
||||
MultiSerialInterface manager;
|
||||
class ReconnectingInterface : public FakeSerialInterface {
|
||||
MultiSerialInterface& manager;
|
||||
public:
|
||||
bool change_session = false;
|
||||
explicit ReconnectingInterface(MultiSerialInterface& owner) : manager(owner) {}
|
||||
size_t checkRecvFrame(uint8_t dest[]) override {
|
||||
if (change_session) {
|
||||
change_session = false;
|
||||
// WiFi's new-IP callback cancels the old contact iterator and then
|
||||
// forgets its route while the manager is still inside this read.
|
||||
manager.unlockReplyRoute();
|
||||
manager.forgetReplyRouteForDisconnected(this);
|
||||
}
|
||||
return FakeSerialInterface::checkRecvFrame(dest);
|
||||
}
|
||||
} wifi(manager);
|
||||
FakeSerialInterface usb, bluetooth;
|
||||
wifi.connected = usb.connected = bluetooth.connected = true;
|
||||
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
|
||||
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
|
||||
ASSERT_TRUE(manager.addInterface(InterfaceType::Bluetooth, &bluetooth));
|
||||
manager.enable();
|
||||
|
||||
wifi.received_frames.push_back({0x04});
|
||||
uint8_t command[MAX_FRAME_SIZE] = {};
|
||||
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
|
||||
manager.lockReplyRoute();
|
||||
|
||||
wifi.change_session = true;
|
||||
wifi.received_frames.push_back({0x16});
|
||||
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
|
||||
ASSERT_EQ(command[0], 0x16);
|
||||
ASSERT_TRUE(manager.isReplyRouteFor(&wifi));
|
||||
const uint8_t device_info[] = {0x0D, 0x43};
|
||||
ASSERT_EQ(manager.writeFrame(device_info, sizeof(device_info)), sizeof(device_info));
|
||||
ASSERT_EQ(wifi.sent_frames.size(), 1u);
|
||||
EXPECT_TRUE(usb.sent_frames.empty());
|
||||
EXPECT_TRUE(bluetooth.sent_frames.empty());
|
||||
|
||||
// Cancelling the prior WiFi iterator also releases its lock, so another
|
||||
// transport may dispatch its own command on the next pass.
|
||||
usb.received_frames.push_back({0x16});
|
||||
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
|
||||
EXPECT_TRUE(manager.isReplyRouteFor(&usb));
|
||||
}
|
||||
|
||||
TEST(MultiSerialInterface, CapturedAsyncReplySurvivesLaterRouteChanges) {
|
||||
MultiSerialInterface manager;
|
||||
FakeSerialInterface usb;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production Wi-Fi transport reconnect ownership and partial writes."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/serial_wifi_sessions"
|
||||
# MinGW does not ship these runtimes. Non-PIE avoids ASan address-space
|
||||
# collisions in Linux hosts, matching the other native regression harnesses.
|
||||
SANITIZER_FLAGS = (
|
||||
["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
if sys.platform.startswith("linux") else []
|
||||
)
|
||||
|
||||
SCENARIOS = (
|
||||
"same IP live replacement keeps replies",
|
||||
"same IP reconnect after disconnect keeps replies",
|
||||
"different IP receives only its own replies",
|
||||
"old IP returns before grace deadline and swaps queues",
|
||||
"old IP expires at exactly 30 seconds",
|
||||
"same IP reconnect does not extend another IP grace deadline",
|
||||
"old IP returns before deadline across millis rollover",
|
||||
"old IP expires across millis rollover",
|
||||
"third IP evicts older parked backlog",
|
||||
"partial writes retain every byte",
|
||||
"higher priority reply cannot interrupt partial frame",
|
||||
"same IP reconnect replays whole partial frame",
|
||||
"different IP return replays whole partial frame",
|
||||
"disable clears both queues and active connection",
|
||||
"end clears both queues and active connection",
|
||||
"different IP callback precedes new input and resets partial RX",
|
||||
"same IP preserves pending operations but resets partial RX",
|
||||
"zero-byte write retains pending frame",
|
||||
"both bounded queues retain four complete responses",
|
||||
"empty different IP takeover preserves existing held backlog",
|
||||
"empty takeover does not extend expiry and parked data does not busy-loop",
|
||||
"full queue admission cannot evict a partly sent frame",
|
||||
"disable cancels owner once and resets partial RX state",
|
||||
"maximum-sized partly sent frame stays intact before higher priority response",
|
||||
"maximum-sized partial frame and parked queue survive owner swap",
|
||||
)
|
||||
|
||||
|
||||
class SerialWifiSessionTests(unittest.TestCase):
|
||||
def test_production_transport_sessions(self):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
self.skipTest("a host C++17 compiler is required")
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-wifi-sessions-") as directory:
|
||||
binary = Path(directory) / "wifi-sessions.exe"
|
||||
compiled = subprocess.run(
|
||||
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
*SANITIZER_FLAGS,
|
||||
f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}",
|
||||
str(FIXTURE / "test.cpp"),
|
||||
str(ROOT / "src/helpers/esp32/SerialWifiInterface.cpp"),
|
||||
"-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
for scenario, description in enumerate(SCENARIOS):
|
||||
with self.subTest(scenario=description):
|
||||
checked = subprocess.run(
|
||||
[str(binary), str(scenario)], capture_output=True,
|
||||
text=True, timeout=10,
|
||||
)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn(f"PASS: Wi-Fi session scenario {scenario}", checked.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user