When BindsTo= takes your MAVLink router down with it

A dependency in the unit graph can silence telemetry with no radio fault at all. Here is how to spot one in the journal, and how to prove it on the bench before anyone swaps a radio.

A companion computer can drop telemetry without any radio fault. If the MAVLink router’s unit is bound to another unit, the router stops when that unit dies, and range control hears nothing.

The journal shows the pattern within milliseconds, but only the unit files can confirm it. Here is how to read the first and check the second.

What the journal shows

The example is INC-0142, an example from the Foxborne example dataset, not a field report. On a Q4 recon quad, the kernel killed perception_node for running out of memory at 14:32:04.118 UTC. On the same companion computer, mavlink-router.service carries MAVLink between the flight controller’s UART and the mesh radio.

uas04-orin-journal.jsonExample dataset, INC-0142, time (UTC), SYSLOG_IDENTIFIER and MESSAGElog
14:32:04.118  kernel   Out of memory: Killed process 2213 (perception_node) total-vm:11873248kB, anon-rss:6823516kB, file-rss:10240kB, shmem-rss:0kB, UID:1001 pgtables:14720kB oom_score_adj:0
14:32:04.121  systemd  perception.service: A process of this unit has been killed by the OOM killer.
14:32:04.133  systemd  perception.service: Main process exited, code=killed, status=9/KILL
14:32:04.134  systemd  perception.service: Failed with result 'oom-kill'.
14:32:04.139  systemd  Stopping MAVLink router...
14:32:04.152  systemd  Stopped MAVLink router.

Read the last two lines closely. systemd wrote them itself, as PID 1, with UNIT=mavlink-router.service, and they name the unit by its Description=, as systemd’s status messages do.1 A router that crashed would log “Main process exited”, as perception did.

This router did not crash. systemd stopped it 34 ms after the kernel’s kill line and 19 ms after it recorded the exit of perception.service. The monotonic timestamps agree: 1,152,938,000 minus 1,152,904,000 µs is 34 ms.

All of these lines come from one journal on one boot, so the intervals do not rest on clock sync between machines. The next piece of evidence comes from another machine.

Range control last heard the vehicle at 14:32:03.912. The next heartbeat was due at 04.912, 0.76 s after the router stopped, and never arrived. Nothing did until 14:32:10.874, 6.962 s after the last one.

PX4 saw the same gap from its side: telemetry_status on TELEM2 reported no ground station heartbeat at 14:32:06.510. It recorded no failsafe, since the gap was shorter than COM_DL_LOSS_T, 10 s on this vehicle.

Time, UTC14:32:0414:32:0614:32:0814:32:10OOM kill, 04.118perception.servicefailed, restart job scheduled at 09.141started 09.214mavlink-router.servicestopped by systemd, 04.152 to 09.203mesh 10.718Ground receive6.962 s without a heartbeat, 03.912 to 10.874First 50 ms04.12004.13004.14004.150Kernelkill lineperception.serviceOOM kill loggedexited, then failedmavlink-router.serviceStoppingStopped19 ms34 ms
Example incident INC-0142. The router’s unit was down for 5.051 s and range control heard nothing for 6.962 s. The lower strip shows the first 50 ms after the kill at a finer scale.

Three dependencies, three behaviors

systemd has three settings that tie one unit’s life to another’s. They differ in exactly the case that matters here: a unit that dies without anyone stopping it. Written on the router, pointing at perception:1

  • Requires= stops or restarts the router when perception is explicitly stopped or restarted.
  • PartOf= does the same, limited to stops and restarts that systemd carries out.
  • BindsTo= does all of that, and stops the router when perception stops unexpectedly or fails.

The man page is explicit that a service exiting on its own does not propagate through Requires=.1 An OOM kill is that kind of exit, and it left perception failed. Of the three, only BindsTo= predicts a router stopped 19 ms after perception’s main process died.

That makes the binding the leading hypothesis, not the only one. A router set to StopWhenUnneeded=, which stops a unit once no active unit requires it, could produce the same two lines.1 So could a script that stops the router when perception fails.

Why it came back

uas04-orin-journal.jsonExample dataset, INC-0142, time (UTC), SYSLOG_IDENTIFIER and MESSAGElog
14:32:09.141  systemd          perception.service: Scheduled restart job, restart counter is at 1.
14:32:09.203  systemd          Started MAVLink router.
14:32:09.214  systemd          Started Perception service.
14:32:10.702  mavlink-routerd  Opened UART [1]fc: /dev/ttyTHS1 baud=921600
14:32:10.718  mavlink-routerd  Opened UDP Client [5]mesh: 10.20.0.2:14550

The router started again 62 ms after perception’s restart job was scheduled. Two kinds of setting could explain that. A Wants= or Requires= in perception.service starts the router whenever perception starts, and BindsTo= and PartOf= both carry restarts across.1

The order of the two “Started” lines says one more thing. With After=perception.service, the router would not start until perception had finished starting.1 The router reported started 11 ms before perception did, so any binding here lacks the After= the man page suggests pairing with it.1

RestartMode= explains why a restart still trips a binding. By default, an automatic restart passes through a failed or inactive state.2 With RestartMode=direct, added in systemd 254, the service skips that state and dependent units are not notified.2

A started unit is not a working link. mavlink-routerd opened the flight controller’s UART 1.499 s after its unit started, and the mesh endpoint 16 ms later. Range control heard the vehicle 156 ms after that.

Test it before anyone swaps a radio

Start with the configuration systemd actually loaded, not the file you expect. The first command is the report’s own next step:5

terminalshell
systemctl show mavlink-router.service -p BindsTo -p PartOf -p Requires
systemctl show perception.service -p BoundBy -p ConsistsOf -p RequiredBy -p Wants
systemctl show mavlink-router.service -p After -p StopWhenUnneeded
systemctl cat mavlink-router.service

The second command reads the same links from the other end. BindsTo= on the router shows up as BoundBy= on perception, and PartOf= as ConsistsOf=.1 systemctl cat prints the unit file and its drop-ins, so you can see which file adds a binding.5

The upstream unit is not the culprit. The one mavlink-router ships names no other unit in its [Unit] section:3

mavlink-router.service.intag v4, lines 1 to 10ini
[Unit]
Description=MAVLink Router

[Service]
Type=simple
ExecStart=@BINDIR@/mavlink-routerd
Restart=on-failure

[Install]
WantedBy=multi-user.target

A binding, if there is one, was added downstream, in a unit file or a drop-in. The project’s README recommends its configuration file over editing the systemd unit.4

Then reproduce the stop on a bench unit running the same image. The OOM killer ends its victim with SIGKILL, so send the same signal to perception’s main process and watch both units:7

terminalshell
# Terminal 1: both units, with microsecond times
journalctl -f -o short-precise -u perception.service -u mavlink-router.service
# Terminal 2: the signal the OOM killer sends, without the memory pressure
kill -s KILL "$(systemctl show -p MainPID --value perception.service)"

If the binding is there, “Stopping MAVLink router...” follows “Main process exited” within milliseconds, as it did in the example. The unit will not report oom-kill this time, and that does not matter: BindsTo= reacts to the stop, not to its cause.1 Time the gap at the ground receiver while you do it.

If the binding is there

Ask whether the router needs perception at all. Its job is to route MAVLink between endpoints over UART, UDP and TCP.4

If perception is the one that needs the router, put the dependency on perception’s side: Wants=mavlink-router.service and After=mavlink-router.service in perception.service. The man page calls Wants= the recommended way to hook one unit’s start-up to another’s.1

If the binding has to stay, RestartMode=direct is the man page’s tool for this case: dependent units are not notified of the temporary failure.2 It needs systemd 254 or later, so check systemctl --version first.5

Either way, run systemctl daemon-reload, since systemd keeps its own view of the unit until you do.5 Then repeat the bench kill. The fix is proven when the router survives it and the ground receiver never notices.

What the evidence shows

  • systemd stopped mavlink-router.service at 14:32:04.152, 34 ms after the kill and 19 ms after perception exited.
  • systemd stopped the router: PID 1 wrote both lines, and mavlink-routerd logged nothing until 14:32:10.702.
  • Range control heard nothing from 14:32:03.912 to 14:32:10.874, 6.962 s.
  • The router started 62 ms after perception’s restart job and opened the mesh link at 14:32:10.718.

What it does not

  • That BindsTo= caused the stop. The unit files and a bench kill decide that.
  • What brought the router back. Wants=, Requires= and restart propagation all fit.
  • When the last heartbeat left the vehicle. Receive times trail it, 412 ms at the 95th percentile on this run.

Sources

  1. 1systemd.unit: Requires=, BindsTo=, PartOf=, Wants=, After=, StopWhenUnneeded=systemd man pages. Accessed September 26, 2026.
  2. 2systemd.service: Restart=, RestartMode=systemd man pages. Accessed September 26, 2026.
  3. 3mavlink-router.service.in, tag v4mavlink-router on GitHub. Accessed September 26, 2026.
  4. 4mavlink-router READMEmavlink-router on GitHub. Accessed September 26, 2026.
  5. 5systemctl: show, cat, --property=, --valuesystemd man pages. Accessed September 26, 2026.
  6. 6journalctl: --unit=, --follow, --output=systemd man pages. Accessed September 26, 2026.
  7. 7mm/oom_kill.c, __oom_kill_process()Linux kernel v5.15. Accessed September 26, 2026.

More field notes

Companion computersReading an OOM kill in a journalctl exportThe kernel’s out-of-memory report names the thread that asked, the process it killed and every page it counted. systemd then records the unit’s result, and two settings decide what happens next.Time and clocksBoot time, wall time, arrival time: one incident, three clocksA PX4 flight log counts from boot, the companion journal keeps wall time and the ground station logs arrival. Give each an explicit error bound, and you know which events you can put in order and which you cannot.Ground linksA telemetry gap is not a radio failureA hole in the ground receive log proves only that nothing arrived. A stopped router, a radio that left the USB bus and a berm all look the same there, and other logs tell them apart.

A pilot on your own data

Bring your hardest incident.

Send one failure you have already investigated. We rebuild it on your data, beside your current tools, and show where the evidence agrees with your conclusion and where it doesn’t.

  1. 1
    Send one incidentA failure you have already investigated, with the flight log and whatever companion or ground evidence you kept.
  2. 2
    We reconstruct itBeside your current tools, on your data, with every claim traced to its source.
  3. 3
    Compare the answersWhere the evidence agrees with your conclusion, where it does not, and what it cannot decide.