One symptom — MAVProxy: Waiting for heartbeat from tcp:127.0.0.1:5760 forever — turned out to be three independent causes at once. TASK-021 21a closed after fixing all three. The sequential-debug history is useful in itself as an example of layered debugging.
Symptom
RiTW: Starting ArduCopter : .../arducopter --model JSON --speedup 1 --slave 0 \
--defaults .../copter.parm,.../gazebo-iris.parm,.../indoor.parm \
--sim-address=127.0.0.1 -I0
Connect tcp:127.0.0.1:5760 source_system=255
Loaded module console
Telemetry log: mav.tlog
Waiting for heartbeat from tcp:127.0.0.1:5760
MAV> ← frozen forever
With --mavros: FCU: Connection problem.
Cause 1 — Orphan UDP 9002 race (dev-log/04)
I checked step by step: arducopter binary alive, param files readable, sim_vehicle.py itself OK, TCP 5760 in LISTEN. Looked at the ArduCopter window:
No JSON sensor message received, resending servos
No JSON sensor message received, resending servos
... (forever)
arducopter --model JSON runs in lock-step: ArduPilot sends servo commands to UDP 127.0.0.1:9002 (the plugin listens), the plugin sends IMU/baro/GPS back — ArduPilot waits for that packet to step and complete init. Without JSON sensor data it doesn’t send the MAVLink heartbeat over TCP 5760.
Socket state after a few sessions:
$ ss -ulnp | grep :9002
UNCONN 127.0.0.1:9002 users:(("ruby",pid=142033,fd=37))
UNCONN 127.0.0.1:9002 users:(("ruby",pid=79368,fd=57))
Two gz sim processes from different runs were bound to UDP 9002 (SO_REUSEADDR/SO_REUSEPORT). The kernel delivered packets non-deterministically — half went to a zombie process, lock-step never closed.
The orphans appeared because daemon.sh stop / a Ctrl-C on the tmux session didn’t reach the children. Linux reparents them to systemd --user, and they keep their bind.
Fix
scripts/shared/d2_sitl_cleanup.sh — --dry-run snapshot, --kill if needed. After every daemon.sh stop, always call the helper. The pre-flight port check in launch.sh catches an occupied MAVLINK_PORT (via TASK-033 ph2), but there’s no such check for UDP 9002 — SO_REUSEPORT silently allows the bind.
Verification 2026-05-08: killed 8 orphans via --kill, started a clean stack — JSON arrives (JSON received: timestamp/imu/position/quaternion/velocity), but arducopter still stalls after validate_structures and doesn’t send heartbeat. Orphans were a contributing factor, not the only cause.
Cause 2 — Cyrillic comments in indoor.parm (dev-log/05)
Enabled ENABLE_DEBUG = 1 in libraries/AP_Param/AP_Param.cpp:62, rebuilt SITL. In the output:
Ignored unknown param а in defaults file .../indoor.parm
(а is a lone Cyrillic ‘a’.)
What’s happening in the parser (AP_Param.cpp)
- Line 2272:
if (line[0] == '#') return false;— comments are detected only at the start of a line. - Line 2325:
char line[100];— 100-byte buffer. fgets(line, 99, file)reads ≤99 bytes per call. A comment longer than 99 bytes splits across multiplefgetscalls — only the first one has#at position 0. The next chunk starts with text → parsed as an unknown parameter.- One unknown param:
done_all_default_params = false. - Lines 1722–1727:
load_object_from_eeprom()callsreload_defaults_file(false)every time a new var subtree registers ANDdone_all_default_params == false. - → infinite reload loop, arducopter never reaches the GCS scheduler → no heartbeat.
UTF-8 makes Cyrillic 2× costly per character. Even a 50-character Russian comment easily overruns the 99-byte buffer. ASCII under ~80 characters fits safely.
Fix
simulation/config/ardupilot/indoor.parm — all comments in ASCII, wc -L = 78.
Bonus finding — ARMING_CHECK → ARMING_SKIPCHK (ArduPilot 4.7)
While debugging, I tried diagnostic-minimal.parm with ARMING_CHECK 0 — also unknown. Source:
libraries/AP_Arming/AP_Arming.cpp:128—// 2 was the CHECK paramterlibraries/AP_Arming/AP_Arming.cpp:201—AP_GROUPINFO("SKIPCHK", 13, ...)- In
init(), the migration:PARAM_CONVERSION - 4.7 CHECK -> SKIPCHK.
Our indoor.parm already used ARMING_SKIPCHK — wasn’t blocking. Noted for future reference.
Verification
With a clean indoor.parm the reload-loop disappeared, but the heartbeat still doesn’t arrive. Stall after validate_structures: Validating structures. This isn’t our indoor.parm and isn’t our world — same behavior with the canonical ardupilot_gazebo/worlds/iris_runway.sdf and without --add-param-file.
Cause 3 — --console flag → MAVProxy matplotlib hang (dev-log/06)
After two fixes it’s still stuck. Walked through bisection:
- ArduPilot master regression — checked out the stable
Copter-4.6.3tag → same stall. - World missing
<spherical_coordinates>— added → still stall (but kept the block, it’s needed for navsat/AHRS init). indoor.parmcontent —diagnostic-minimal.parm(onlyARMING_SKIPCHK 1+BATT_MONITOR 0) → still stall.iris_claudedronemodel — swapped for canonicaliris_with_gimbal→ heartbeat in 4 s. False positive: stripped sg90 → works, stripped sensors_link → works, full model → no heartbeat. “Model broken” hypothesis didn’t reproduce until one variable changed.launch.shvs directsim_vehicle.py— model-bisect tests usedsim_vehicle.py --no-mavproxy, whilelaunch.shuses--consoleand letssim_vehicle.pylaunch MAVProxy itself. Direct with--consolereproduces the stall. Direct without--console— heartbeat in 3 s.
What --console does
Launches matplotlib via MAVProxy’s “console” UI module. In the output was an ignored warning:
/home/aleks/venv-ardupilot/lib/python3.12/site-packages/matplotlib/projections/__init__.py:63:
UserWarning: Unable to import Axes3D. This may be due to multiple
versions of Matplotlib being installed...
Not fatal, but MAVProxy’s “console” wedges on a downstream import after this path, never returns control to the heartbeat loop. arducopter is running fine (backtrace: AP_Scheduler::loop() → wait_clock → JSON::recv_fdm → select — normal lockstep wait); it’s just that its messages went through MAVLink (which MAVProxy was supposed to render), not to stdout.
Fix
simulation/help_scripts/launch.sh — remove --console from cmd_sitl(). MAVProxy works in plain terminal text mode, prints heartbeats inline.
End-to-end: ./help_scripts/launch.sh --sim --headless -d -log → heartbeat in ~6 s, mode STABILIZE, AP: ArduCopter V… Frame: QUAD/X. Stable on master (4.8.0-dev) and Copter-4.6.3.
Why the dev-log/04 hypothesis tree didn’t catch this immediately
dev-log/04’s candidate list: lock-step mismatch, EKF init, plugin schema vs data — all three misdirections. Autopilot was healthy. Real diagnosis became possible when:
- I waited long enough (~30-40 s, not 25) to see whether heartbeat starts on canonical setups.
- I compared two
sim_vehicle.pyinvocations side-by-side, rather than trustinglaunch.shoutput as ground truth.
Rules for future SITL bring-up debugging
- First check —
pgrep -af "gz sim|mavproxy|arducopter"andd2_sitl_cleanup.sh --dry-run. The orphan UDP 9002 race is a real failure class. .parmfiles — ASCII only,wc -L ≤ 78. TheAP_Param100-byte buffer is a real constraint.- Compare direct
sim_vehicle.py --no-mavproxywithlaunch.shbefore blaming world/model. - No
--consoleflag in production launch. Plain terminal mode is enough. <spherical_coordinates>inworld.sdf— pre-emptive hygiene for navsat/AHRS, add it right away.- If debug-needed —
ENABLE_DEBUG = 1inlibraries/AP_Param/AP_Param.cpp:62, rebuild SITL. It’s a lazy hack — return it to 0 afterward.
Sources
~/drone_media/sim/TASK-021/21a_sitl.log— stall and recovery~/drone_media/sim/TASK-021/21a_mavros.log—FCU: Connection problem→connected: truesimulation/help_scripts/launch.sh(historical commit) —--consoleremovedsimulation/config/ardupilot/indoor.parm— comments rewritten in ASCII