Skip to content

Rework MAVLink dataflash log download: reliability, cancellation, and tests - #3764

Open
userepo wants to merge 9 commits into
ArduPilot:masterfrom
userepo:fix/log-download-mavlink
Open

Rework MAVLink dataflash log download: reliability, cancellation, and tests#3764
userepo wants to merge 9 commits into
ArduPilot:masterfrom
userepo:fix/log-download-mavlink

Conversation

@userepo

@userepo userepo commented Aug 27, 2026

Copy link
Copy Markdown

Problem

Log download over MAVLink has long-standing reliability and performance problems
(#3461, and the class of complaints in #3387 and the forum's many
"slow log download" threads). In MAVLinkInterface.GetLog:

  • Received blocks were tracked in a Hashtable keyed by the decimal string of
    ofs/90, and every 500 ms retry tick re-scanned all blocks from zero — on a
    large log with a late gap, millions of string allocations per tick.
  • Both download phases polled with Thread.Sleep(10), adding up to 10 ms of
    latency per packet independent of link speed.
  • A short LOG_DATA packet ended the streaming phase even when it arrived out
    of order, silently truncating the download.
  • The fill-in phase requested a phantom block past EOF whenever the log size
    was an exact multiple of 90 bytes.
  • There was no cancellation: closing the download form only closed the form —
    the transfer (and the vehicle's streaming) kept running, and the partial temp
    file was leaked.
  • After every download, the whole log was re-parsed (DFLogBuffer) just to
    extract a GPS timestamp for the filename.

Changes

ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs:

  • Track received blocks in a HashSet<uint> with a lowest-missing cursor;
    finding the first gap is O(1) instead of an O(n) string-keyed rescan.
  • Replace polling with a semaphore signalled per received LOG_DATA packet.
    The waits carry the timeouts, so the loops only run when a packet arrives or
    a deadline genuinely expires.
  • Only treat a short packet as end-of-log at the highest offset seen, and use a
    correct ceiling block count (no phantom block past EOF).
  • Add an optional CancellationToken (public signature otherwise unchanged;
    the [Obsolete] GetLog(ushort) wrapper still compiles and behaves the same).
  • Send LOG_REQUEST_END when a download is abandoned, so the vehicle stops
    streaming and the next log operation works immediately.
  • Delete the partial file on timeout/cancel/link loss.
  • Split the protocol into an internal GetLogInternal(..., filename, ...) that
    takes the destination path, with the retry/refetch timeouts injectable —
    defaults and public behavior are unchanged; this is what makes the code unit
    testable.

Log/LogDownloadMavLink.cs:

  • Closing the form (after confirming) now actually cancels the transfer.
  • The downloaded file is named from LOG_ENTRY.time_utc; the full-log GPS scan
    runs only as a fallback when the vehicle reports no valid time. This removes
    a second full pass over every downloaded log.
  • Progress bar is scaled 0–1000 so multi-GB batch downloads no longer overflow
    the int-based ProgressBar.

Behavior changes to be aware of

  • The streaming-phase timeout now means "3 s with no LOG_DATA received" rather
    than a rolling deadline since the last accepted packet. These differ only
    when a second GCS is downloading a different log on the same link — and the
    new behavior avoids retry-spamming a busy vehicle.
  • Filenames come from the vehicle-reported time_utc instead of a GPS-message
    scan (same times in practice; the old scan remains as the fallback).

Tests

  • tests/MissionPlanner.ArduPilot.Tests — xUnit suite (7 tests) driving the
    protocol against an in-memory fake vehicle over CommsInjection: in-order
    and reordered transfers, dropped-block refetch, exact-multiple-of-90 logs,
    empty logs, cancellation (including LOG_REQUEST_END), and timeout. Runs in
    ~3 s via the built exe or dotnet test.
  • tests/MissionPlanner.ArduPilot.SitlTests — manual end-to-end harness plus a
    MAVLink-aware lossy proxy and a README with the SITL setup recipe.

Neither project is in MissionPlanner.sln, so CI is unaffected.

Verification against ArduPilot SITL (Copter stable, TCP)

  • Clean link: 2,306,048-byte log downloaded in ~0.45 s (~5 MiB/s),
    byte-for-byte identical to the log file SITL wrote to its own disk.
  • 5% LOG_DATA loss (1,348 of 26,971 frames dropped by the proxy): fill-in
    phase recovered every gap; result still byte-identical.
  • Cancel mid-download: prompt OperationCanceledException, no leaked temp
    file, and the very next LOG_REQUEST_LIST succeeds.

Not exercised end-to-end: tlog-replay mode (logreadmode) and MAVLink1-only
vehicles (covered by the protocol logic and unit tests, but SITL testing used
MAVLink2 over TCP).

Also included: Microsoft.Windows.CsWin32 pin in ExtLibs/WinUSBNet bumped
0.3.268 → 0.3.269 (the version NuGet already resolves — 0.3.268 is no longer
on the feed) to fix the NU1603 restore warning.

Addresses #3461.

- track received blocks in a HashSet with a lowest-missing cursor instead
  of a string-keyed Hashtable rescanned from zero every retry tick
- wait on a semaphore signalled per LOG_DATA packet instead of polling
  with Thread.Sleep(10); only enqueue LOG_DATA in the packet handler
- add optional CancellationToken and delete the partial temp file on
  timeout/cancel/link loss
- only treat a short packet as end-of-log at the highest offset seen, so
  a reordered short chunk cannot truncate the download; use a ceiling
  block count (old len/90+1 requested a block past EOF on exact multiples)
- remove per-download JSON serialization of the packet event list

LogDownloadMavLink: cancel the transfer when the form closes, name the
file from LOG_ENTRY time_utc instead of re-parsing the whole download
(DFLogBuffer scan kept as fallback), scale the progress bar to 0-1000 so
>2GB batches do not overflow it, and fix the rename error message
showing the destination path twice.
Let the semaphore wait carry the retry/re-request timeout instead of
looping on a DateTime deadline; replace the start=MinValue re-request
hack with a RequestFirstMissing local function.
Cover the log download protocol with an in-memory fake vehicle in
tests/MissionPlanner.ArduPilot.Tests: complete/reordered/dropped-block
transfers, exact-multiple-of-90 logs, empty logs, cancellation, and
timeout. Split the protocol into internal GetLogInternal taking the
destination path, make the retry/refetch timeouts injectable, and add
InternalsVisibleTo for the test assembly. Pin CsWin32 to the resolved
0.3.269 to silence NU1603.
A cancelled or failed GetLog left the vehicle streaming LOG_DATA, which
made the next log operation fail with "Existing log download already in
progress". Found via SITL end-to-end testing; covered by a unit test.
Console harness that lists and downloads a log from a live ArduPilot
SITL vehicle, verifies it byte-for-byte against the log file SITL wrote
to disk, and exercises mid-download cancellation. Includes a MAVLink-
aware lossy proxy for fill-in phase testing and a README with the SITL
setup recipe and reference results.
@userepo userepo changed the title Fix/log download mavlink Rework MAVLink dataflash log download: reliability, cancellation, and tests Aug 27, 2026
@userepo
userepo marked this pull request as ready for review August 27, 2026 03:19
@userepo

userepo commented Aug 27, 2026

Copy link
Copy Markdown
Author

Hi @meee1, could you take a look at this PR.
I am also working on a rewrite of the dataflash log core in Rust: https://github.com/userepo/MissionPlanner/tree/rust/dflog-core. However, it would be great if the current PR is merged first.

@meee1
meee1 requested a lite review from Copilot August 27, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Reworks MAVLink dataflash log download flow to improve reliability and cancellation behavior, and adds dedicated automated/manual test harnesses for verification.

Changes:

  • Adds xUnit protocol-level unit tests using an in-memory fake vehicle + CommsInjection.
  • Updates log download UI to support cancellation, avoid unnecessary re-parsing for timestamps, and prevent ProgressBar overflow on large logs.
  • Refactors MAVLinkInterface.GetLog internals (block tracking, retry/refetch logic, cancellation, cleanup) and bumps CsWin32 package version to resolve restore warnings.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
tests/MissionPlanner.ArduPilot.Tests/MissionPlanner.ArduPilot.Tests.csproj Adds a net472 xUnit test project referencing production projects.
tests/MissionPlanner.ArduPilot.Tests/GetLogTests.cs Implements unit tests for in-order/reordered transfers, refetch, EOF edge cases, cancellation, and timeout.
tests/MissionPlanner.ArduPilot.SitlTests/lossy_proxy.py Adds a lossy TCP proxy to simulate LOG_DATA drops for manual SITL testing.
tests/MissionPlanner.ArduPilot.SitlTests/README.md Documents the manual SITL test harness and lossy-link procedure.
tests/MissionPlanner.ArduPilot.SitlTests/Program.cs Adds a manual end-to-end console harness for listing + downloading + optional cancel/oracle compare.
tests/MissionPlanner.ArduPilot.SitlTests/MissionPlanner.ArduPilot.SitlTests.csproj Adds a net472 console harness project and copies the proxy script to output.
Log/LogDownloadMavLink.cs Wires cancellation into the UI, improves naming/timestamps, and rescales progress display.
ExtLibs/WinUSBNet/Nefarius.Drivers.WinUSB.csproj Bumps Microsoft.Windows.CsWin32 package version.
ExtLibs/ArduPilot/MissionPlanner.ArduPilot.csproj Exposes internals to the new test assembly.
ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs Refactors GetLog implementation for reliability, cancellation, and unit-testability.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Log/LogDownloadMavLink.cs
Comment thread Log/LogDownloadMavLink.cs Outdated
Comment thread Log/LogDownloadMavLink.cs Outdated
Comment thread Log/LogDownloadMavLink.cs Outdated
Comment thread Log/LogDownloadMavLink.cs
Comment thread Log/LogDownloadMavLink.cs Outdated
Comment thread Log/LogDownloadMavLink.cs
Comment thread ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs Outdated
Comment thread tests/MissionPlanner.ArduPilot.SitlTests/lossy_proxy.py Outdated
Comment thread tests/MissionPlanner.ArduPilot.SitlTests/lossy_proxy.py Outdated
- GetLog: unsubscribe the Progress handler in a finally so throws and
  cancels cannot leak it
- clamp the scaled progress value; the sender may deliver more bytes
  than LOG_ENTRY reported
- dispose CancellationTokenSources: on replacement, on download
  completion, and guard the Cancel/Dispose race on form close
- filter LOG_DATA by sysid/compid before enqueueing so unrelated
  traffic cannot grow the queue or reset the silence timers
- lossy_proxy.py: bytearray buffer instead of O(n^2) bytes slicing
- exclude tests/** from the app project's compile globs
Harden GetLog against malformed packets and fill-in stalls

- skip LOG_DATA whose count exceeds the 90-byte payload instead of
  aborting the download
- ignore fill-in data beyond the log length established by the end
  marker so a bogus offset cannot extend the file
- bound the fill-in phase with the same total silence budget as the
  streaming phase (3x LogDataTimeoutMs) instead of re-requesting forever
- only a newly received block triggers the immediate fill re-request, so
  stale or duplicated packets on an echoing link cannot multiply
  LOG_REQUEST_DATA traffic
- only packets near the contiguous frontier raise the end-of-log bar, a
  corrupt far offset cannot poison end detection; the log length comes
  from the end packet itself and the file is truncated to it
- two new tests, both failing before the fix; SITL clean and lossy runs
  re-verified byte-identical (0.42 s / 55.5 s)

@fallenmi fallenmi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new end-detection guard still trusts a corrupt short packet when the packet is also at a far offset. On exact head a99de123, I injected a valid-looking LOG_DATA packet with ofs=1_000_000 and count=40 after five normal blocks, then continued the real 1,234-byte stream. GetLogInternal() treats that packet as the end marker and the download fails with Timeout on read - GetLog fill-in instead of returning the log.

The packet is correctly excluded from maxEnd because it is beyond endDetectionSlack, but the later EOF condition does not apply the same trust boundary: data.ofs + data.count >= maxEnd is true for the far offset, so the code sets totallength to 1,000,040. The existing tests cover a far packet only at count=90 and a stray short packet only at an old near-stream offset; they do not combine the two properties.

Please require an end packet to satisfy the same near-contiguous-frontier condition before it can terminate the streaming phase (or validate it against an independently known log size), and add the combined regression. Applying that same-frontier predicate to the EOF branch makes the new case pass, and all 13 GetLogTests pass together. There are currently no published checks for this head.

Disclosure: I used OpenAI Codex to inspect the exact revision and construct and run this MAVLink packet-sequence regression; I verified the failure and the minimal guarded variant locally.

A LOG_DATA packet that is both short and at a corrupt far offset cleared
the end-of-log bar trivially: maxEnd is only raised by packets near the
contiguous frontier, so ofs + count >= maxEnd held and the streaming
phase ended at a phantom length, failing the download in the fill-in
phase.

Requiring the end packet itself to sit near the frontier is not enough:
on a lossy link the frontier stalls at the first dropped block, and the
genuine end of a large log then sits far past any fixed trust window.
Rejecting it forces the whole stream to be re-sent per recovered gap.

Instead, an end packet past the trusted window becomes a candidate that
is only accepted once the stream goes quiet: a corrupt packet is
followed by more stream, the real end is not. Corrupt far packets are
also kept away from the retry offset, and only packets carrying new
blocks reset the retry budget, so a resent stale packet cannot keep the
streaming phase alive forever.

Verified against SITL: clean-link download unchanged (0.44s byte
identical), 5% LOG_DATA loss completes in 58.4s byte-identical, where
a plain near-frontier requirement on the end packet never completed.
@userepo

userepo commented Aug 30, 2026

Copy link
Copy Markdown
Author

The new end-detection guard still trusts a corrupt short packet when the packet is also at a far offset. On exact head a99de123, I injected a valid-looking LOG_DATA packet with ofs=1_000_000 and count=40 after five normal blocks, then continued the real 1,234-byte stream. GetLogInternal() treats that packet as the end marker and the download fails with Timeout on read - GetLog fill-in instead of returning the log.

The packet is correctly excluded from maxEnd because it is beyond endDetectionSlack, but the later EOF condition does not apply the same trust boundary: data.ofs + data.count >= maxEnd is true for the far offset, so the code sets totallength to 1,000,040. The existing tests cover a far packet only at count=90 and a stray short packet only at an old near-stream offset; they do not combine the two properties.

Please require an end packet to satisfy the same near-contiguous-frontier condition before it can terminate the streaming phase (or validate it against an independently known log size), and add the combined regression. Applying that same-frontier predicate to the EOF branch makes the new case pass, and all 13 GetLogTests pass together. There are currently no published checks for this head.

Good catch, thank you, @fallenmi. Confirmed on a99de12 and fixed in ed2d40b.

One note on the fix shape: I tried the same-frontier predicate on the EOF branch first, and while all unit tests pass with it, the SITL lossy harness rejected it - at 5% loss the contiguous frontier stalls at the first dropped block, so the genuine end packet of a 2.3 MB log sits far past any fixed trust window, and rejecting it forces the whole stream to be re-sent for every recovered gap (the proxy logged five full re-streams without completing). So an end packet past the trusted window is now a deferred candidate instead: it only ends the streaming phase if the stream then goes quiet - a corrupt mid-stream packet is followed by more data, the real end is not.

Your combined regression is added, plus three related ones (retry-offset hijack by the same packet, stale resends must not keep resetting the retry budget, and a stalled-frontier end must hand over to fill-in rather than re-stream). 16 tests pass, and re-verified against SITL: clean 0.44 s byte-identical, 5% loss 58.4 s byte-identical.

@userepo
userepo requested a review from fallenmi August 30, 2026 02:54
userepo added a commit to Poholos/MissionPlanner10 that referenced this pull request Aug 30, 2026
A LOG_DATA packet that is both short and at a corrupt far offset cleared
the end-inference bar trivially: only frontier-near packets raise the
bar, so end >= bar held and the download ended at a phantom length.
Past the true end that stalls the repair phase into a timeout; below it
the phantom silently truncated the returned file.

Rejecting far end packets outright is not an option: packet loss stalls
the frontier at the first dropped block, so the genuine end of a large
log always sits far past the trust window, and every recovered gap then
forces the vehicle to re-stream the whole log (that variant never
finished the lossy SITL run). Instead the tracker records a deferred
candidate, discarded when more stream arrives, and GetLog promotes it
once a silence window expires: a corrupt packet is followed by more
stream, the real end is not.

Found via review of the equivalent change on the upstream PR
(ArduPilot#3764).

SITL: clean unchanged (0.44 s byte-identical), 5% LOG_DATA loss 77.3 s
byte-identical in one streaming pass - the earlier 72.9 s reference plus
one silence window confirming the end. Harness README reference results
and the STATUS.md checkpoint updated to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants