Correct the one-controller claim: the drone takes over, it does not refuse

Tested on the aircraft. A second ARSDK handshake is accepted and telemetry is
redirected to it; the first session's frames stop while it still reports
connected = True. So the claim inherited from pyparrot's error text, which had
reached our error messages, tool descriptions, simulator behaviour and a test
name, was wrong in the most misleading direction: a refusal would be loud, and
this is silent.

The simulator now models the takeover by default; refusal stays available
because a client must handle a non-zero status anyway.
This commit is contained in:
Ryan Malloy
2026-10-02 03:44:05 -06:00
parent ad81cc6e38
commit 41a6cb107f
6 changed files with 86 additions and 12 deletions
+15 -4
View File
@@ -119,7 +119,14 @@ async def test_handshake_describes_this_controller(sim, session):
assert request["d2c_port"] == session.d2c_port != 0
async def test_second_controller_is_refused(sim, session):
async def test_second_controller_takes_the_link_over(sim, session):
"""The aircraft accepts a newcomer and stops talking to whoever had it.
Measured on the real drone 2026-10-02: the first session's frame count
froze while the second received, and the first went on reporting
connected = True. That silent failure is why a client should judge a link
by telemetry freshness rather than by its connected flag.
"""
other = DroneSession(
"127.0.0.1",
discovery_port=sim.discovery_port,
@@ -128,9 +135,13 @@ async def test_second_controller_is_refused(sim, session):
encoder=fake_encode,
decoder=fake_decode,
)
with pytest.raises(HandshakeError, match="one controller"):
await other.connect()
assert not other.connected
await other.connect()
try:
assert other.connected
# The loser keeps its socket and its optimism; only the data stops.
assert session.connected
finally:
await other.disconnect()
async def test_unreachable_address_fails_fast_rather_than_hanging():
+16
View File
@@ -55,3 +55,19 @@ def test_acknowledgement_is_decided_by_data_type_not_buffer():
frame = Frame(DataType.DATA_WITH_ACK, buffer_id, 9, b"\x00\x05\x00\x00")
assert Frame.decode_all(frame.encode())[0].buffer_id == buffer_id
assert 0 <= BufferId.ack_for(buffer_id) <= 255
def test_simulator_models_takeover_not_refusal_by_default():
"""The aircraft accepts a second controller; it does not refuse one.
Tested on the real drone 2026-10-02: session A's frames froze at 264 while
session B took over, and A went on reporting connected = True. The
simulator defaulted to refusing, which is the same unverified assumption
the client carried, so no test could catch the difference.
"""
import dataclasses
from mcbebop.sim import FakeBebop
field = next(f for f in dataclasses.fields(FakeBebop) if f.name == "single_controller")
assert field.default is False, "the default must model the aircraft, not the folklore"
+31 -2
View File
@@ -74,6 +74,16 @@ def sim():
yield fake
@pytest.fixture
def sim_factory():
"""For tests that need the refusing simulator rather than the default.
The default models the aircraft: a second controller is accepted and the
first is starved. Refusal is the opt-in mode.
"""
return FakeBebop
@pytest.fixture
def controller(sim):
client = Controller(sim)
@@ -165,15 +175,34 @@ def test_handshake_is_accepted_and_names_a_c2d_port(sim, controller):
assert sim.handshakes[0]["controller_name"] == "test"
def test_a_second_controller_is_refused(sim, controller):
def test_a_second_controller_is_accepted_by_default(sim, controller):
"""Matching the aircraft, which accepts a newcomer and starves the first.
Tested on the real drone 2026-10-02. The refusal this test used to assert
was folklore inherited from pyparrot's error text.
"""
second = Controller(sim)
try:
assert second.reply["status"] == 1
assert second.reply["status"] == 0
finally:
second.close()
def test_a_second_controller_can_be_refused_when_asked(sim_factory):
"""Kept because a client must still handle a non-zero status."""
with sim_factory(single_controller=True) as strict:
first = Controller(strict)
second = Controller(strict)
try:
assert first.reply["status"] == 0
assert second.reply["status"] == 1
finally:
second.close()
first.close()
def test_the_slot_is_free_again_after_release(sim, controller):
"""Only meaningful in the strict mode; the default never withholds a slot."""
assert sim.occupied
sim.release()
third = Controller(sim)