ArduPilot / ArduPilot/MissionPlanner

SerialOutputPass (Ctrl+F > Mavlink): mirrors started from the grid can never be stopped

Open
#3,768 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C#
Stars
2.4k
Forks
2.9k
Avg merge
19h 16m
Merged PRs (30d)
4

Description

#### Issue details

MAVLink mirrors started from the grid in the `SerialOutputPass` window (Ctrl+F > Mavlink, also
reachable from Config > Planner > Advanced) cannot be stopped. Once started, the output keeps
running for the whole lifetime of the Mission Planner process.

I hit this in normal use: I had set up an output to a particular address and had to close and
reopen Mission Planner to stop it, because nothing in the window stops an output once it is
started. I then reproduced it against SITL and measured it.

**Steps to reproduce**

1. Connect to a vehicle or SITL (I used Simulation > Multirotor).
2. Start any UDP listener on the target port. I used a small Python script that counts packets per
second and distinct source ports.
3. Ctrl+F > Mavlink.
4. Add a grid row: `Type=UDP`, `Direction=Outbound`, `Port=14561`, `Extra=127.0.0.1`,
`Write=unchecked`.
5. Click the `Go` cell. The cell shows "Started" and MAVLink starts flowing to 127.0.0.1:14561.
6. Try to stop it.

**Expected:** the stream stops.

**Actual:**

- Clicking `Go` again does not stop the mirror. It **adds another mirror to the same destination**,
so the receiver gets the same packets again. Every further click adds one more.
- Disconnecting the vehicle pauses the traffic (there is no vehicle data to mirror), but the mirrors
survive: on reconnect the **same** sockets resume sending.
- There is no other UI affordance that stops it. The legacy `Connect`/`Stop` button, together with
the serial port / baud / write-access controls, has been `Visible = False` since 3827d9a
(2024-11-06), so it cannot be reached either.
- The only way to stop the output is to restart Mission Planner.

**Measurement**

One grid row, target 127.0.0.1:14561, SITL multirotor. Packets per second measured at the target,
alongside the number of distinct UDP source ports (each source port is one socket, i.e. one `Mirror`
object on the Mission Planner side):

| event | distinct source ports | packets/s at target | duplicate payloads/s |
|---|---|---|---|
| 1st `Go` click | 1 | 64 | 0 |
| after clicks 2-5 | 5 | 320 | ~260 |
| 6th click (intended to stop it) | 6 | 380 | ~325 |
| vehicle Disconnect | 6 (idle) | 0 | 0 |
| vehicle reconnect | 6 - *the same six ports* | 380 | ~325 |
| Mission Planner closed | 0 | 0 | - |

The source ports observed were 53325, 54785, 54786, 54787, 54815 and 53236 - one new socket per
click, and after the disconnect/reconnect cycle all six came back, which is what a fix has to
address: the `Mirror` objects outlive the link.

Throughput scales linearly with the click count (1x = 64 pps, 5x = 320 pps, 6x = 380 pps), and the
duplicate-payload count is non-zero from the second click onwards, i.e. the target receives each
MAVLink packet once per accumulated mirror.

Image

Image
Counter output as clicks 2-5 land: one new source port per click, `dup` non-zero from the second click on.

Image
After disconnecting and reconnecting the vehicle, the same six source ports resume.

#### Analysis

Verified against `master` at 2b5589f4.

`Controls/SerialOutputPass.cs`, `myDataGridView1_CellContentClick` (L197-271) only ever adds:

```csharp
MainV2.comPort.Mirrors.Add(mirror); // L263
myDataGridView1[Go.Index, e.RowIndex].Value = "Started"; // L264
Started.Add(e.RowIndex); // L265
```

There is no `Close()`, no `Mirrors.Remove()`, and no "already started" check anywhere in the method.
The only `Close()` in the whole file is at L55, inside the hidden legacy `BUT_connect_Click`.

In `ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs`, `Mirrors.Clear()` appears exactly once in the
whole file, inside `Dispose()` (L6848). `Dispose()` is only ever invoked from `MainV2.cs` L3038,
which explicitly skips the primary interface:

```csharp
// skip primary interface
if (port == comPort)
continue;
```

Grid mirrors are attached to `MainV2.comPort`, i.e. the primary interface, so the list is never
cleared while the application runs - which matches the observation that the same six sockets came
back after a disconnect/reconnect cycle. `ProcessMirrorStream()` (L5463) writes to every entry on
every packet.

Related problems in the same area:

- `static private List Started` (L190) is only ever appended to, never cleared, and it is keyed
by row *index*, so it breaks if rows are added, removed or reordered.
- The TCP Inbound path uses a single `static TcpListener listener` (L19). Starting a second TCP
Inbound row overwrites it (L217); the first listener is orphaned and can never be stopped.
`listener.Stop()` is never called anywhere in the file.
- `Mirrors` is a plain `List`. `ProcessMirrorStream` enumerates it on the telemetry read thread
(`readPacketAsync`, L4667), while the UI thread calls `Mirrors.Add`. A concurrent modification
throws `InvalidOperationException` out of the `foreach`, which is swallowed by the outer
`catch (Exception ex) { log.Error(ex); }` in `readPacketAsync` (L5452-5455) - so it silently drops
packet processing rather than crashing. Adding a remove path makes this more likely to be hit, so
it should be handled at the same time.

Two smaller things I ran into while reproducing, both in the same handler:

- Clicking `Go` on a row whose `Write` checkbox has never been toggled fails with
`Error: Object reference not set to an instance of an object.` The `Write` column
(`SerialOutputPass.Designer.cs` L132) is a `DataGridViewCheckBoxColumn` with no `TrueValue` /
`FalseValue` / `ThreeState` set, so the cell value is `null` until it is edited, and L208 calls
`.Value.ToString()` on it unguarded. The same applies to the other four cells if left empty.
- The `Go` button renders with no label until a row is started. `Go.Text = "Go"` is set
(`SerialOutputPass.Designer.cs` L138) but `UseColumnTextForButtonValue` is not, so the button
shows the cell value, which is `null` until the code writes "Started" into it.

This looks like an unfinished feature rather than intended behaviour. The
`SerialOutputPass: add list` (7f3150e) and `SerialOutputPass: multi mirrors` (3827d9a) commits only
ever added start paths; the latter also hid the legacy Connect/Stop UI without providing a
replacement.

Two further items I deliberately leave out of a fix, noting them here:

- `MirrorStream` / `MirrorStreamWrite` (`MAVLinkInterface.cs` L427-459) insert an empty `Mirror`
into the list when it is empty, as a side effect of the *getter*. `SerialOutputPass`'s constructor
triggers this (L27, L35), so `Mirrors[0]` is usually a dummy. Changing it would affect
`Controls/SerialSupportProxy.cs` (L22, L53-55, L79-80) and `plugins/example12-forwarding.cs`
(L50, L55, L74), which rely on those compatibility properties, so it needs its own change.
- `DoAcceptTcpClientCallback` (L132) casts `ar.AsyncState` to
`ValueTuple`, while the legacy `BUT_connect_Click` (L74)
passes a bare `TcpListener`. That path is currently unreachable because the button is hidden, but
the mismatch is still there.

Related, still-open reports about this same window: #2085, #2416, #2194.

I'm happy to open a PR that turns the `Go` cell into a proper start/stop toggle, tracks running
mirrors and listeners per row, and takes a lock around the `Mirrors` iteration.

#### Version

Mission Planner 1.3.83 (build 1.3.9384.38258), Windows 11 Home 25H2 (build 26200.9168).
Source analysis done against `master` at 2b5589f4.

#### Platform

[ ] All
[x] Mission Planner (GCS only, not vehicle specific)

#### Airframe type

N/A - GCS side issue, reproduced against SITL multirotor.

#### Hardware type

N/A - reproduced against SITL.

#### Logs

N/A - no vehicle log needed. Reproducible with any UDP listener on the mirror target; the graph
above was produced from a packet counter binding the target port.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in Controls/SerialOutputPass.cs at myDataGridView1_CellContentClick and inspect how grid rows create mirrors and TCP listeners. Then read MAVLinkInterface.cs, especially ProcessMirrorStream and the Mirrors lifecycle around Dispose. Reproduce with SITL and a UDP listener; done means a row can stop its output, repeated clicks do not duplicate streams, disconnects leave no stale mirrors, and mirror iteration remains safe.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
desktop, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.