Skip to content

Sync backend with pymavlink implementation - #2050

Open
amilcarlucas wants to merge 5 commits into
masterfrom
upstream_mavftp
Open

amilcarlucas wants to merge 5 commits into
masterfrom
upstream_mavftp

Conversation

@amilcarlucas

Copy link
Copy Markdown
Collaborator

Description

Replace the local MAVFTP backend with pymavlink's implementation and adapt consumers to the FtpError and DirectoryEntry APIs.

Prevent callback-owned downloads from writing virtual remote paths as local files, and update the related tests and fixtures.

Checklist

  • Run pre-commit checks locally
  • Verified by a human programmer
  • All commits are signed off (use git commit --signoff)
  • Code follows our coding standards
  • Documentation updated if needed
  • No breaking changes or properly documented

Testing

Describe how you tested these changes:

  • Unit tests pass
  • Integration tests pass
  • Manual testing performed
  • Tested on flight controller hardware

Copilot AI lite review requested due to automatic review settings September 9, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The download completion path currently reads entire staged downloads into memory before writing to disk, which is unsafe for large flight logs and should be fixed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR updates the project’s MAVFTP integration to align with pymavlink’s mavftp_op API, replacing the local FTP operation implementation and adapting application code and tests to the new FtpError and directory-entry structures. It also hardens download handling so callback-driven “virtual” remote paths aren’t accidentally treated as local filenames.

Changes:

  • Replace local MAVFTP opcodes/error constants with pymavlink FTP_OP ops and a new FtpError enum, and update consumers accordingly.
  • Switch directory listings to a structured DirectoryEntry list API and update flight-log listing logic + fixtures.
  • Update factories and tests to support session reset semantics during MAVFTP initialization.
File summaries
File Description
ty.toml Excludes backend_mavftp.py from ty checks.
pyproject.toml Excludes backend_mavftp.py from ruff and ignores mypy errors for the module.
.pylintrc Excludes backend_mavftp.py from pylint.
ardupilot_methodic_configurator/backend_mavftp.py Reworks MAVFTP to use pymavlink’s FTP ops, introduces FtpError/DirectoryEntry, and adjusts transfer/session handling.
ardupilot_methodic_configurator/backend_flightcontroller_params.py Updates parameter download flow to use FtpError.Success semantics.
ardupilot_methodic_configurator/backend_flightcontroller_files.py Updates file operations and directory listing consumption for the new APIs.
tests/test_backend_mavftp.py Updates tests to use FtpError and adds coverage for callback downloads not writing virtual remote paths.
tests/test_backend_mavftp_aux.py Updates MAVFTPReturn tests for FtpError values and messages.
tests/test_backend_flightcontroller_sitl.py Updates log-directory creation checks to use FtpError.
tests/test_backend_flightcontroller_files.py Updates mocks/fixtures to use DirectoryEntry lists.
tests/test_backend_flightcontroller_factory_mavftp.py Adds a reset-session-aware mocked master for factory tests.
Review details
  • Files reviewed: 10/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

Comment on lines 936 to 960
self.fh.seek(0)
result = self.fh.read()
if self.read_to_memory:
self.get_result = result[: self.requested_size]
else:
self.get_result = result
assert self.get_result is not None # noqa: S101
if len(self.get_result) < self.requested_size:
logging.warning(
"expected %u, got %u", self.requested_size, len(self.get_result)
)
logging.info("read %u bytes", len(self.get_result))
self.fh.flush()
try:
if publish_result and self.filename and self.filename != "-":
# Move the result to the final location
logging.info("Moving %s to %s", self.temp_filename, self.filename)
with open(self.filename, "wb") as final_file:
final_file.write(self.get_result)
finally:
# terminate the remote session and release the staging
# file even when the destination cannot be written
self.__terminate_session()
self.read_complete = True
return True
Comment on lines +2079 to +2082
while True:
while len(data) > 0 and data[0] == pad_byte:
data = data[1:] # skip pad bytes

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
20285 18226 90% 89% 🟢

New Files

No new covered files...

Modified Files

No covered modified files...

updated for commit: 7342b85 by action🐍

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Test Results

     4 files       4 suites   34m 13s ⏱️
 5 228 tests  5 221 ✅  7 💤 0 ❌
20 690 runs  20 651 ✅ 39 💤 0 ❌

Results for commit 7342b85.

♻️ This comment has been updated with latest results.

@amilcarlucas amilcarlucas added the AIReview Request an automated AI review; picked up by the reviewprs sweep label Sep 9, 2026
@tridge

tridge commented Sep 10, 2026

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-10)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Reviewed at head 374e9a8c54. Full report: https://uav.tridgell.net/DevCallReviews/2026_09_10_AIReview/devcall_pr_reviews.html#prMethodicConfigurator-2050

Verdict: COMMENT — no blockers. Nothing here will corrupt user data, and the question most likely to have been broken — whether the pinned pymavlink release actually provides what the new code imports — checks out against the real PyPI wheel. Three things are worth changing before merge.

The flight-log download path now buffers the whole file in RAM, and keeps it

ardupilot_methodic_configurator/backend_mavftp.py:937

This is a regression against the code being replaced, not a pre-existing wart. The base implementation streamed straight to the destination — __handle_open_ro_reply opened self.filename directly and __check_read_finished never read it back. The new code stages to a tempfile (good) and then does an unconditional result = self.fh.read(), keeping it on the instance as self.get_result.

Measured rather than asserted — driving __check_read_finished with a 67 MB staging file under tracemalloc:

file size          : 67.1 MB
tracemalloc peak   : 70.2 MB
retained after call: 67.1 MB  (self.get_result)

self.get_result is only ever consumed by the synchronous read() path (backend_mavftp.py:709); for the cmd_get-to-file path it's pure waste, and it's never cleared. ArduPilot .BIN logs run several times that size and download_last_flight_log is a shipped feature (backend_flightcontroller_files.py:164).

This confirms and strengthens Copilot's inline comment at backend_mavftp.py:960 — their suggested os.replace() of the staging file is the right fix and removes the retention too. One caveat: tempfile.mkstemp(prefix="mavftp_") at backend_mavftp.py:834 uses TMPDIR/%TEMP%, frequently a different filesystem from the destination, so os.replace will raise OSError: [Errno 18] Invalid cross-device link. Either pass dir=os.path.dirname(os.path.abspath(self.filename)) to mkstemp, or use shutil.move.

An unwritable destination now fails after the whole transfer, as an uncaught OSError

ardupilot_methodic_configurator/backend_mavftp.py:953

The destination is opened at the end of the transfer, inside a bare try: … finally: with no except. The base code opened it at OpenFileRO-reply time inside try/except Exception and returned a clean MAVFTPReturn("OpenFileRO", ERR_FileNotFound) before a single byte was transferred.

Reproduced by pointing self.filename at a non-existent directory:

RAISED out of __check_read_finished: FileNotFoundError [Errno 2] No such file or directory: '.../definitely/not/here/00000042.BIN'
staging file still on disk: None

There's no try/except around self.__mavlink_packet(m) in process_ftp_reply, so it propagates. The staging file is correctly released (that finally comment is accurate), and _download_log_file (backend_flightcontroller_files.py:394) has a broad except Exception that turns it into return False — so it isn't a crash. But the user waits out a multi-hundred-megabyte download before being told the path is bad, and gets a raw strerror instead of the structured MAVFTPReturn. Validating or opening the destination in __handle_open_ro_reply, alongside the staging file, would restore the old behaviour.

Four lint/type relaxations were added; three silence 12 diagnostics between them, one silences nothing, and the justifying comment is wrong

Each count came from removing that one relaxation and running the project's own pinned tool from a venv built with pip install -e .[dev]:

file:line change messages actually silenced
.pylintrc:7 ignore=decode_devid.py,backend_mavftp.py 2
pyproject.toml:193 ruff exclude 138
pyproject.toml:270 mypy ignore_errors = true 10
ty.toml:12 src.exclude 0
  • pylint 4.0.5: exactly two messages, both R0917 too-many-positional-arguments (lines 199 and 2215), score 9.99/10 — both fixable with the # pylint: disable= idiom already used elsewhere in the same file. Excluding the file also disables this repo's own enable=useless-suppression / fail-on=useless-suppression for it, and ruff independently reports 4 RUF100 unused-noqa in the new code, so stale suppressions are already accumulating in there.
  • mypy 2.3.1: 10 errors, 6 of them Unused "type: ignore" comment. The two substantive ones (:513, :834) are both fixed by annotating self.temp_filename: Optional[str] = None at backend_mavftp.py:414. A blanket ignore_errors = true for a 2691-line MAVLink I/O module is a lot of collateral for that.
  • ty 0.0.74: All checks passed! — the exclusion silences nothing. I checked that ty really was analysing the file rather than skipping it another way, by appending a deliberately wrong return type and confirming it reported error[invalid-return-type].
  • ruff: 138 errors versus 0 on the base version of the same file. The bulk is the legacy typing style adopted wholesale from upstream — 39×UP006, 16×UP007, 10×UP045, 4×UP035 — on a project with requires-python = ">=3.10" and target-version = "py310", plus 26×ANN001. I take it the real motivation is ruff-format: the file is now in upstream's 88-column style and ruff format --diff would rewrite 435 lines against this project's line-length = 127. That's a fair reason to exclude formatting; it doesn't require giving up the 138 lint rules.

The justification is also wrong on the facts. .pylintrc:5-6 reads "Synchronized verbatim from ArduPilot by update_flightcontroller_ids.yml; do not lint the local copy", and backend_mavftp.py was added to that line — but grep -rn backend_mavftp .github/workflows/ returns nothing (no workflow syncs it, unlike decode_devid.py, which update_flightcontroller_ids.yml:59 copies verbatim), and it is not verbatim: against pymavlink/mavftp.py extracted from the released 2.4.49 wheel, 915 of 2691 lines match — 34% identical, 1773 added and 753 removed. It's a heavily diverged fork holding the app's flight-log and parameter I/O, and the green ruff/pylint/mypy checks at this head are green because of these four entries. Please either fix the ~12 real diagnostics and keep the checks, or at minimum correct the comment so the next reader knows the file is unlinted by choice rather than because it's a mirror.

ftp_param_decode's repeated slicing is a regression — this is the bit Copilot couldn't have known

backend_mavftp.py:2043. I agree with Copilot's comment at :2082, and the important context is that the base implementation was already offset-based and O(n) (__decode_param_record(data, offset, …), which is in this PR's removed set). Syncing to upstream threw away this project's own optimisation.

That said, I measured it rather than leaving it as a scare: 500 params 0.51 ms, 1000 1.09 ms, 2000 2.44 ms, 4000 5.99 ms. Clearly superlinear (8× params → ~12× time) but ~1 ms at a realistic ArduPilot parameter count, against a MAVFTP transfer measured in seconds. Low priority — worth restoring because the better version already existed here, not because users will notice.

On tolerating a bad parameter-count header (backend_mavftp.py:2150)

Your motivation checks out — ArduPilot itself concedes the header count can be wrong (AP_Filesystem_Param.cpp:195-198 invalidates its cached count_parameters() for exactly this reason), so rejecting the whole download over it is wrong.

My concern is that count != num_params was the only end-to-end record-count integrity check on the packed file, and the result isn't used benignly: backend_flightcontroller_params.py:258-261 writes the decoded set to complete_param_filename as the FC's "complete" snapshot, which the editor then diffs against the intended values. A genuinely short transfer would yield a silently partial snapshot in which missing parameters look like parameters the FC doesn't have.

The new test test_param_decode_keeps_records_when_header_count_differs (tests/test_backend_mavftp.py:161) packs count=1 with two records — i.e. only count > num_params, the harmless direction. I couldn't construct a case where a short transfer actually reaches this code (the reached_eof/read_gaps machinery and the len(self.get_result) < self.requested_size warning at :943 should catch it first), so I'm marking the risk unconfirmed. Suggestion: tolerate count > num_params and keep rejecting count < num_params, which is what truncation looks like. Separately — this commit diverges from pymavlink upstream in the very function being synced, so it should go upstream too or the next sync silently reverts it.

The new "completed upload" fast path names an operation no caller passes

backend_mavftp.py:1938 scopes it to operation_name.lower() == "put", but both upload call sites pass "CreateFile"backend_flightcontroller_files.py:115 and the untouched mavftp_example.py:188.

An independent Codex pass reported this as a bug that makes an acknowledged upload read as timed out. I checked and that part is wrong, so please don't chase it: tests/test_backend_flightcontroller_sitl.py:1145 and :1194 drive files_mgr.upload_file() against a live ArduCopter SITL MAVFTP server, .github/workflows/pytest.yml:178 runs them with -m "sitl or not sitl" on Linux, and pytest (ubuntu-latest, 3.10) and (ubuntu-latest, 3.14) are both green at this head with 5159 tests and 0 failures — so the ordinary completed_opcode == last_op.opcode path does complete the upload. The fast path is simply never taken, and its comment claims to cover a case it can't reach. Either drop it or match on the opcode rather than the caller-supplied display name. Worth noting that every upload test in test_backend_flightcontroller_files.py mocks process_ftp_reply outright, so the unit suite wouldn't distinguish the two.

Minor

  • backend_mavftp.py:952 logs "Moving %s to %s" but performs a read-and-rewrite. Becomes accurate if the first finding is fixed with os.replace.
  • backend_mavftp.py:653-710: read() and read_sector() are new and unused anywhere in this package (they exist upstream for mavftpfs) — ~60 lines of dead code with a hardcoded 5 s timeout and a time.sleep(0.0001) busy loop.
  • backend_mavftp.py:14: a stray # FLAKE_CLEAN marker, the only occurrence in the repo.
  • The file header drops This file is part of ArduPilot Methodic Configurator…, which 81 of the 91 modules in ardupilot_methodic_configurator/ carry.
  • CLI default --idle_detection_time changes 1.2 → 3.7, a real behaviour change for the shipped mavftp console script (it now matches the library default, so arguably a fix — just worth being deliberate).
  • data_model_parameter_editor.py:1192-1199: the new "Uploaded and verified N parameters in M ms" timer starts at line 1131, before upload_parameters_that_require_reset_workflow, which can block on a confirmation dialog and an FC reboot+reconnect — so the number will sometimes be dominated by human think-time. The test can't catch this because it mocks that workflow out entirely; it also patches time with exactly two values, so any future time() call on that path breaks it with StopIteration.

What was checked and is clean

The pymavlink dependency is fine. pyproject.toml:57 pins pymavlink==2.4.49 both before and after this PR, and backend_mavftp.py:51 imports FTP_OP and the OP_* constants from pymavlink.mavftp_op. I downloaded the actual PyPI wheel pymavlink-2.4.49-cp312-…-manylinux_2_28_x86_64.whl and AST-parsed it: it ships pymavlink/mavftp_op.py exporting exactly OP_None … OP_Nack and FTP_OP, whose signature, pack() and items() are behaviourally identical to the class this PR deletes. Every imported name is present in the released wheel — no module-scope-constant trap. FtpError and DirectoryEntry are defined locally (backend_mavftp.py:77, :111), not imported, so no version coupling there either.

No missed call sitesgrep -rn "ERR_[A-Za-z]" over the whole package returns only unrelated hits in scripts/generate_pdef.xml_metadata.py; directory_listing is consumed only at backend_flightcontroller_files.py:282; and the one MAVFTP consumer not touched by this PR, mavftp_example.py, uses only FTP_OP and .items(), both still valid.

Path traversal is clean — the obvious worry given the "remote paths as local filenames" framing. The FC-supplied listing is only used to parse a log number (backend_flightcontroller_files.py:285-290), the local destination is built from an int, and the single-argument cmd_get path uses os.path.basename. No remote-controlled string reaches os.path.join or open().

Both new tests were proven by mutation, not by reading. Flipping backend_mavftp.py:899 from publish_result = False back to True makes test_successful_callback_does_not_write_virtual_remote_path fail, with the captured log showing the bug it guards: Moving None to param.pck?withdefaults=1. Restoring the old logging.error(...) + return None at :2150 makes the count-mismatch test fail at its assert result is not None. And the underlying bug is a genuinely good catch — cmd_getparams calls cmd_get(["@PARAM/param.pck?withdefaults=1"], callback=…) with one argument, so cmd_get sets self.filename = os.path.basename(fname) = param.pck?withdefaults=1, a name containing ?, illegal on Windows, which the old code would have tried to create.

A local run on this head in a dev venv gives 450 passed, 49 skipped across the seven affected test files. The one red check, Publish Tests Results, is the coverage report --fail-under=89 gate, which fails identically on master for this repo; all 5159 tests pass with 0 failures.

Not covered: the rewritten burst-read and gap-repair machinery (__handle_burst_read, __idle_task, __reply_matches_active_request, BURST_REPLY_SEQUENCE_WINDOW, MAX_READ_GAPS) is the largest behavioural surface in the diff and was only spot-checked — it plausibly deserves its own pass. Also not covered: cmd_put/__handle_create_file_reply beyond confirming the new fh_owned ownership logic, the GUI layer, Windows behaviour (reasoned about, not executed), and no hardware testing.

Reviewed by Claude plus an independent Codex pass that was shown only the PR number, never these findings.

@tridge

tridge commented Sep 15, 2026

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-15)

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Re-reviewed at head 38e208b6af; my earlier comment above is superseded. Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_16_0152/devcall_pr_reviews.html#prMethodicConfigurator-2050

Scope: this review covers the three commits up to 38e208b6af.

  • You pushed 5053b0bb85 ("make reset reconnect resilient and deterministic") at 17:08Z, after this review had finished.
  • That commit isn't reviewed here. The next follow-up run will pick it up.

Verdict: COMMENT (unchanged).

  • Previous round: 1 finding is resolved, 1 is partly resolved, and the rest are still open. There were no replies to dispute.
  • Nothing new blocks this. The main new concern is that the sync copies in an upstream PR that is still under review.

Resolved

  • Header-count tolerance: the commit and its test are gone, and the strict check is back at backend_mavftp.py:3310-3312, matching upstream.
    • Nothing tests it any more: making it tolerant again leaves all 104 mavftp/files/factory tests passing.

Partly resolved

  • Unwritable destination: now caught at backend_mavftp.py:1582-1588 and returned as Get/Fail.
    • It is still only detected after the whole download, because the destination isn't opened until :1580.
    • Removing the handler leaves the tests green.

Still open

  • Whole-file RAM buffering on download (backend_mavftp.py:1560, :1566, :1580-1581).

    • A 64 MiB file peaks at 67.2 MB and leaves all 64 MiB retained in get_result.
    • mkstemp(dir=<destination dir>) at :1415 plus os.replace avoids both.
    • The same code is in pymavlink#1274.
  • Lint exclusions are unchanged (.pylintrc:7, pyproject.toml:193, pyproject.toml:270-272, ty.toml:12).

    • The "synchronized by update_flightcontroller_ids.yml" comment is still wrong: no workflow syncs this file.
    • At this head they hide 2 pylint R0917, 8 mypy unused type: ignore and 201 ruff messages, mostly style. The pylint and mypy entries could simply go.
  • Upload fast path: it still keys on "put" (:3020, :3055, :3091), but AMC passes "CreateFile" (backend_flightcontroller_files.py:115). So every AMC upload waits out the 3.7 s idle detection. Measured against Copter SITL:

    file size "CreateFile" "put"
    1 KB 3.79 s 0.05 s
    20 KB 4.17 s 0.52 s
    200 KB 8.28 s 4.64 s
    • All of these are inside AMC's 10 s timeout, and the behaviour is the same as AMC master.
    • Passing "put", or matching "CreateFile", saves about 3.7 s per upload. It also stops a transfer longer than about 6.3 s from hitting that timeout.
  • ftp_param_decode slicing (:3246, :3292): low priority, and in upstream too.

  • Minor, unchanged:

    • The "Moving" log at :1579 is really a read and rewrite.
    • read()/read_sector() are unused (:1246-1313). I withdraw my earlier "busy loop" wording: read() blocks in recv_match with a 1 s timeout.
    • There is a stray # FLAKE_CLEAN at :14, and the project header line is missing.
    • The CLI idle default changed from 1.2 to 3.7 (:3575).
    • The upload timer starts before the reset workflow (data_model_parameter_editor.py:1131), and its test feeds time exactly two values (tests/test_data_model_parameter_editor.py:4121).

New

  • Copied from an unmerged upstream PR.
    • The file matches pymavlink#1274 (open, head 0ea16d6aa6) on 3962 of 3963 lines, against 2322 lines for pymavlink master.
    • That brings in about 1650 new lines (link simulation, crccmp, ListDirectoryWithTime probing, RTT timing, batched writes) while upstream review can still change them.
    • Please land build(deps): bump astral-sh/setup-uv from 7.2.1 to 7.3.0 #1274 first, or name the upstream PR/SHA in the commit message.
  • No AMC tests were added for it.
    • Both mutations above survive.
    • Batched upload (:2178) and the list-with-time fallback (:1219-1230) have no unit coverage here, although upstream's tests do exercise them.
  • 5e8526ab1c doesn't import against the pinned pymavlink==2.4.49 (ImportError: OP_ListDirectoryWithTime).
    • 38e208b6af fixes it; please squash the two so bisect keeps working.
    • The shim itself (:78-85) is fine.
  • Every listing probes opcode 16 first (the list_time default at :485).
    • ArduPilot replies UnknownCommand and the code falls back (:1219-1230).
    • AMC creates a fresh MAVFTP per operation, though, so the probe is repeated on every listing. Low priority.
  • Wall-clock timer: the upload duration uses time() (data_model_parameter_editor.py:1131, :1199); time.monotonic() is safer.
  • Low priority, and inherited from pymavlink master, so better fixed in build(deps): bump astral-sh/setup-uv from 7.2.1 to 7.3.0 #1274:
    • A write error on the staging file mid-download (e.g. ENOSPC) escapes as OSError from :1603, leaving the handle open and the mavftp_* temp file orphaned.
    • mavftp get <remote> - from the console script (:1537) no longer prints the file, which AMC master did.
  • Unreachable here: __send_batch swaps master.mav.file (:880), which would be unsafe with a second sender thread. AMC has no such thread.

Checked:

  • Tests: 445 passed and 53 deselected across the seven affected test files (Python 3.14, pymavlink 2.4.49).
  • Mutations: the two described above.
  • SITL: upload timings and byte-identical results.
  • vermin: minimum Python 3.7.

Not checked in depth: the burst, RTT and batch state machines, Windows, and hardware.

CI at 38e208b6af: 34 passing, 1 pending, and 1 failing. The failure is Publish Tests Results, which also failed at the previous head; the test-results bot reports 0 test failures.

@tridge

tridge commented Sep 15, 2026

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-15)

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Re-reviewed at head 5053b0bb85; my earlier comment above is superseded. Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_16_0447/devcall_pr_reviews.html#prMethodicConfigurator-2050

This covers 5053b0bb85 ("make reset reconnect resilient and deterministic"), which my previous comment left out. The three earlier commits are unchanged.

Verdict: REQUEST CHANGES (was COMMENT).

  • Previous round: 1 finding resolved, 1 partly resolved, the rest still open. 5053b0bb85 touches none of them, and there were no replies to dispute.
  • Why the verdict moved: 5053b0bb85 adds two user-visible regressions. Claude and Codex each measured both independently, and both are cheap to fix.

Should fix (new in 5053b0bb85)

  1. Every failed connection is now about 3x slower, not just the reconnect after a reset.

    • create_connection_with_retry now loops attempts = max(1, retries) times (backend_flightcontroller_connection.py:883). It still passes the same retries into pymavlink (:898), so the two retry counts multiply. The previous head made one attempt.

    • The loop serves every connection path, not just the reset: the connect dialog, startup and auto-detect. It all runs on the Tk thread.

    • It also retries failures a second attempt won't fix: "No supported autopilots found" (:914) and a missing AUTOPILOT_VERSION (:919).

    • Measured with FlightController.connect(), previous head vs this head:

      scenario before after
      TCP connection refused 2.0 s 8.0 s
      TCP open, no heartbeat 5.0 s 17.0 s
      auto-detect, no FC plugged in 3.0 s 10.0 s
    • Suggest looping only on the reconnect path and retrying only port-open or no-heartbeat failures, with one layer owning the retry count.

  2. The reboot countdown is gone, so the restart window freezes.

    • The 1 s callback loop became a single time_sleep(sleep_time) on the Tk thread (backend_flightcontroller.py:307). That is 8 s plus the extra delay.
    • Parameter editor: against ArduCopter SITL the total reconnect time is unchanged (14.3 s). But the window now gets no update for 8.0 s, where it used to get one per second.
    • Motor test: DelayedProgressCallback (plugins/frontend_tkinter_motor_test.py:83-91, wired at :521) swallows the 10% call. Nothing is shown for 10.3 s, where it used to appear after 1.3 s.
    • Keeping a per-second countdown through the reconnect callback would fix both.

Minor

  • Label is wrong and the bar goes backwards.

    • The 90% "Waiting for MAVLink HEARTBEAT" update is sent after heartbeats arrive (backend_flightcontroller_connection.py:908), while the code is actually waiting for the banner and AUTOPILOT_VERSION.
    • On a handshake retry the bar goes 30 → 90 → 50 → 90 → 100 (measured against a local MAVLink responder).
  • The stage-to-label mapping is copied three times.

    • The copies are at frontend_tkinter_parameter_editor.py:204-218 and :1664-1678, and plugins/frontend_tkinter_motor_test.py:486-499.
    • Each hard-codes "of 3" and inverts the formula at backend_flightcontroller_connection.py:888.
    • One helper, or letting the backend send the stage text, would stop them drifting.
  • The restart window can be reused after it is destroyed.

    • get_connection_progress_callback caches the window (frontend_tkinter_parameter_editor.py:197), and reaching 100% destroys it (frontend_tkinter_progress_window.py:154-155).
    • If the user picks Retry after a validation failure (data_model_parameter_editor.py:1171) and that pass resets again, no restart window is shown.
    • It doesn't crash, because update_progress_bar checks winfo_exists(). The previous head created a fresh window each time.
  • The tests miss most of the new behaviour. Of 19 mutations run against the 789 tests in the touched files, 11 survived. The survivors include:

    • no retry on unsupported-autopilot or version/banner failure, which the commit message claims;
    • no disconnect() between attempts;
    • deleting the reboot time_sleep(sleep_time) entirely;
    • all of the motor-test restart progress.

    Only the port-open retry is tested directly (tests/test_backend_flightcontroller_connection.py:1829), and that test mocks heartbeat and version retrieval to succeed.

  • Reconnect reopens the same device name.

    • For an auto-detected serial port, comport.device is the resolved /dev/ttyACMx, so a board that comes back under a different name won't reconnect. This was checked with injected discovery, not on hardware.
    • The retry does help when the same name just comes back late.
  • Dead code: return _("Connection failed.") at backend_flightcontroller_connection.py:948 is unreachable. The two branches in FlightController.create_connection_with_retry (backend_flightcontroller.py:423-438) differ only in forwarding one kwarg.

Previous findings

  • Resolved: header-count tolerance. The strict check is at backend_mavftp.py:3310-3312.
  • Partly resolved: unwritable destination. It is caught (backend_mavftp.py:1582-1588), but only after the whole download.
  • Still open, unchanged:
    • whole-file RAM buffering on download (backend_mavftp.py:1560, :1566, :1580-1581);
    • lint exclusions, with the wrong "synchronized by" comment (.pylintrc:7, pyproject.toml:193, :270-272, ty.toml:12);
    • the "put" upload fast path vs AMC's "CreateFile" (backend_mavftp.py:3020, backend_flightcontroller_files.py:115);
    • ftp_param_decode slicing (backend_mavftp.py:3246, :3292);
    • the copy of the still-open pymavlink#1274;
    • no AMC tests for the sync;
    • squashing 5e8526ab1c with its import fix;
    • the opcode-16 probe on every listing;
    • the minor mavftp items (ENOSPC staging leak, get <remote> -, "Moving" log, unused read(), file header).
  • Upload timer: this commit edited those lines, but the timer still starts before the reset workflow and still uses wall-clock time() (data_model_parameter_editor.py:1117, :1124, :1184).

What was checked

  • Tests and lint: 789 tests pass across the 8 touched test files (Python 3.14, pymavlink 2.4.49). ruff, pylint and pyright are clean on the changed files.
  • Timing probes: refused, silent and auto-detect connects, plus a real ArduCopter SITL reset_and_reconnect, all compared with 38e208b6af.
  • Codex separately reproduced the retry multiplication (9 socket connects in 8.0 s) and the motor-test freeze.
  • Earlier commits: the parent of 5053b0bb85 is 38e208b6af, so they are unchanged.
  • Not checked: USB re-enumeration on hardware, a Windows COM-port reopen, and SITL over UDP.

CI at 5053b0bb85: 30 passing, 1 skipping, and 1 failing. The failure is Publish Tests Results, as at the last two heads; the test-results bot reports 0 test failures.

@amilcarlucas
amilcarlucas force-pushed the upstream_mavftp branch 3 times, most recently from 24f9501 to d886207 Compare September 16, 2026 19:23
@AP-Review

Copy link
Copy Markdown

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Re-reviewed at head d88620722e (previously reviewed at 5053b0bb85); my earlier comment above is superseded.

Full report, including everything that was checked and found clean: https://uav.tridgell.net/DevCallReviews/2026_09_17_AIReview/devcall_pr_reviews.html#prMethodicConfigurator-2050

Verdict: REQUEST CHANGES

A large, careful sync, and the previous round's two blockers are both fixed and measured: whole-file RAM buffering went from 67.3 MB peak / 67.1 MB retained at 5053b0bb85 to 0.0 MB at this head for a 64 MiB file, and the retry multiplication is gone (1 factory call with retries=3 on the normal path, byte-identical to base). Twelve of the eighteen previous findings are resolved, and the mutation battery over the touched test files improved from 11-of-19 survivors to 1-of-8. The divergence hunt is the important result below — this file is a copy of an unmerged upstream PR, with four deliberate local fixes that a future re-sync would revert. Two blockers remain, one of which the primary read missed and the independent Codex pass caught.


BUG — ardupilot_methodic_configurator/backend_mavftp.py:1630

The batch write path bypasses WebSocket framing, putting raw MAVLink bytes on a WebSocket connection. __write_link_data() takes is_stream to mean "a raw byte stream I may write straight to" and calls port.sendall(data), skipping link.write(). But is_stream is computed at :1670 as link_name in ("mavtcp", "mavtcpin") or port_type == socket.SOCK_STREAM, and pymavlink's mavwebsocket.port is the socket returned by self.listen.accept() on a SOCK_STREAM listener — so port_type == SOCK_STREAM is true and the second clause fires. mavwebsocket.write() is precisely where the framing lives (packed = self.ws.send(BytesMessage(data=buf)) then self.port.send(packed)), so bypassing it emits unframed MAVLink that the peer cannot parse. Reachable: backend_flightcontroller_protocols.py:54-55 explicitly declares mavutil.mavwebsocket and mavwebsocket_client as supported link types, and add_connection() accepts an arbitrary connection string — so a user who adds a wsserver: link hits it on any MAVFTP batch (len(operations) > 1). Suggest gating the fast path on the link type (an allowlist of mavtcp/mavtcpin) rather than on the socket's SOCK_STREAM-ness, or simply preserving link.write() for any wrapped transport.

BUG — ardupilot_methodic_configurator/backend_flightcontroller_connection.py:468

Reconnect stops at the first heartbeat, so any second component on the link fails the reconnect outright. Three defects combine into one concrete failure. :468 returns on the first heartbeat regardless of who sent it; :918 sets retryable_failure = False before _select_supported_autopilot, so that failure never retries and never keeps listening; and :888 keys the retry policy on a UI callback (reconnecting := reconnect_progress_callback is not None), so reset_and_reconnect(progress_callback=None) silently gets zero resilience. Reproduced: with heartbeats (1,190, MAV_AUTOPILOT_INVALID) then (1,1, ARDUPILOTMEGA) injected, the reconnect path returns 'No supported autopilots found' after one attempt, while the normal connect path and the pre-PR code both return ''. Reachable through any link carrying a second component — mavlink-router or MAVProxy hub, a companion computer, a gimbal. tests/test_backend_flightcontroller_connection.py:1256 locks in the early return but only with a single ArduPilot heartbeat. Cheapest fix: return early only when the heartbeat is from a supported autopilot, and move the retryable_failure = False below the _select_supported_autopilot check.

ISSUE — ardupilot_methodic_configurator/backend_mavftp.py:78

This file is a copy of an unmerged upstream PR, and four deliberate local fixes are absent from it. pyproject.toml:57 pins pymavlink==2.4.49, whose mavftp.py is 1671 lines and shares only 843 with this file. It is a copy of the still-open ArduPilot/pymavlink#1274 (head caa17cc9f0): 3972 of 4045 lines match. Diffing against that head gives exactly five divergent hunks, all deliberate: the OP_ListDirectoryWithTime ImportError shim at :78-85 (needed — 2.4.49's mavftp_op exports 18 OP_* constants and not that one, and the upstream value 16 matches), mkstemp(dir=<destination>) at :1415, publish-by-os.replace at :1557-1610, the OSError guard at :1621-1631, and the offset-based ftp_param_decode at :3302-3382. All four fixes are absent from #1274, so a future re-sync silently reverts them unless they are upstreamed first. The ftp_param_decode divergence itself is safe: a 300-trial randomised differential test against #1274's slicing decoder, extracted by AST and executed side by side, gave 0 mismatches.

ISSUE — ardupilot_methodic_configurator/backend_mavftp.py:1416

Four tool exclusions plus the coverage omission leave ~2300 added lines unmeasured and unlinted. .pylintrc:8, pyproject.toml:194, pyproject.toml:270-273 and ty.toml:13 between them hide 2 pylint R0917, 8 mypy (all "Unused type: ignore"), 202 ruff and 1 ty diagnostic; .coveragerc:7 (pre-existing) also omits the file. No test references list_time, OP_ListDirectoryWithTime or MAVLinkBatchWriter, so ~1650 imported upstream lines have neither coverage nor measurement — which is how the WebSocket bug above reached this head. Also :1416: downloads now land with mode 0600 from mkstemp instead of the umask default, and nothing chmods after os.replace; and :1415's dir=destination_dir is the one mutation that survived — dropping it makes every download fail EXDEV wherever TMPDIR is a different filesystem.

ISSUE — ardupilot_methodic_configurator/plugins/data_model_motor_test.py:381

Four public methods drop a positional parameter while the PR body ticks "No breaking changes". set_parameter, set_motor_spin_arm_value, update_frame_type_from_selection and update_frame_type_by_key all lose a positional callback argument. Also backend_flightcontroller_files.py:22: the try/except ImportError fallback around the MAVFTP import is gone, so a pymavlink without mavftp_op now fails at import rather than degrading; and frontend_tkinter_parameter_editor.py:103 weakens Callable[[tk.Misc, str, str, bool], ProgressWindow] to Callable[..., ProgressWindow].

Lower-priority notes (1)

ardupilot_methodic_configurator/backend_mavftp.py:1542 — Smaller drifts worth a pass. :1542 — "Wrote %u/%u bytes to %s" now names the hidden staging file, not the destination. :1571get_result is no longer populated for file downloads (only read_to_memory), a divergence from upstream's contract, unused inside AMC. :3642 — the shipped mavftp console script's --idle_detection_time default changes 1.2 → 3.7. :14 — a stray # FLAKE_CLEAN, the only occurrence in the repo, and the "This file is part of ArduPilot Methodic Configurator" header that 81 of 91 modules carry is still missing. backend_flightcontroller_connection.py:945 — "Failed to connect after %d attempts" now reports 1 on the normal path. frontend_tkinter_progress_window.py:176-190 — the helper hardcodes "of 3", so the 30/50/70 mapping silently degrades if retries != 3.

What was checked and found clean

Twelve previous findings RESOLVED, and the CI failure is not this PR's. Both blockers fixed: retry multiplication (1 factory call with retries=3 on the normal path, byte-identical to base; 3 calls with retries=1 on reconnect) and the frozen reboot window (per-second callback restored). Also resolved: triplicated stage labels; window reuse after destroy; wall-clock timer → perf_counter; "CreateFile""put"; whole-file RAM buffering (measured 67.3 MB → 0.0 MB); unwritable destination now fails at OpenFileRO-reply time before any transfer; ENOSPC staging leak; get <remote> - printing; ftp_param_decode slicing; the bisect-breaking commit squashed; the wrong submodule-sync comment corrected. Both Copilot inline comments resolved. API sweep: every module-scope ERR_* constant and the local FTP_OP class are gone (FtpError enum now), but grep -rn "ERR_[A-Z]" across the repo hits only unrelated names, and mavftp_example.py:202's mavftp.FTP_OP still resolves via the re-export; all 11 distinct mavutil.mavlink.* references exist in 2.4.49 and all 5 commits import cleanly against it. Tests: 5066 passed / 5 failed / 23 errors in a fresh venv — the failures and errors are all tests/gui_* (pyautogui/tooltip) and reproduce identically on the merge-base. CI: Publish Tests Results is not a test failure and not a fork artifact — the PR is same-repo, the publish step succeeded, and it fails at coverage report --fail-under=89 with 88%; the last two master runs fail at the same step with the same 88%.

@AP-Review

AP-Review commented Sep 16, 2026

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-16)

Correction to my review above — AI-generated (Claude).

ArduPilot/pymavlink#1274 moved from caa17cc9f0 to 514d94b3d4 while that review was being written, and the new commit
(514d94b3d4, "feat(mavftp): avoid buffering completed downloads") changes one of my findings.

I said four MethodicConfigurator-local fixes were absent from #1274 and would be reverted by a future re-sync. Three of
them are now upstream
and that part of the finding no longer applies:

What still stands:

  • The OP_ListDirectoryWithTime try/except ImportError shim remains MethodicConfigurator-local, and correctly so — it
    exists because the pinned pymavlink==2.4.49 lacks that opcode, which is not upstream's problem.
  • The offset-based ftp_param_decode still differs; build(deps): bump astral-sh/setup-uv from 7.2.1 to 7.3.0 #1274 at 514d94b3d4 is still on struct.unpack("<HHH", data[0:6]).
  • The underlying point is unchanged: backend_mavftp.py is a copy of an unmerged upstream PR, so it needs re-checking
    against build(deps): bump astral-sh/setup-uv from 7.2.1 to 7.3.0 #1274 whenever that PR moves — as it just did, twice in one day.

Everything else in the review above is unaffected, including the two blockers.

@amilcarlucas amilcarlucas mentioned this pull request Sep 16, 2026
10 tasks
Log the number of uploaded parameters and elapsed verification time using
descriptive, order-independent placeholders. Add regression coverage for
the new message.
Replace the local MAVFTP backend with pymavlink's implementation and
adapt consumers to the FtpError and DirectoryEntry APIs.

Prevent callback-owned downloads from writing virtual remote paths as
local files, and update the related tests and fixtures.
@amilcarlucas
amilcarlucas force-pushed the upstream_mavftp branch 2 times, most recently from 2bf74ab to f1fb612 Compare September 16, 2026 22:23
Replace separate reset and connection progress callbacks with one
progress callback for the complete restart workflow.

Emit deterministic restart progress stages:
- 10% after the reset command is sent
- 30%, 50%, and 70% for reconnect attempts
- 90% after MAVLink heartbeat detection
- 100% after the controller handshake completes

Retry failed USB-port opens, heartbeat detection, and version/banner
handshakes so a flight controller that re-enumerates after reboot can
reconnect successfully.

Use one shared restart/reconnect progress window in the parameter editor
and motor-test plugin while keeping parameter-download progress unchanged.
Add support for replacing a progress window message between stages and
update acceptance and unit tests accordingly.

BREAKING CHANGE: reset_progress_callback and
connection_progress_callback are replaced by a single progress_callback.
@AP-Review

AP-Review commented Sep 16, 2026

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-16)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Re-reviewed at head f1fb6124ae; my earlier comment above is superseded.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_17_0922/devcall_pr_reviews.html#prMethodicConfigurator-2050

Both blockers from last round are fixed, and both are mutation-proven by two independent reviewers.

  • RESOLVED (blocker 1) backend_mavftp.py:907 — the WebSocket framing bypass. is_stream is now link_name in ("mavtcp","mavtcpin") with the socket-type term dropped and an explaining comment. Restoring the term fails test_websocket_batch_uses_link_write_for_websocket_framing; unmutated it passes.
  • RESOLVED (blocker 2) backend_flightcontroller_connection.py:468 — reconnect no longer stops at the first heartbeat. Reverting :468 reproduces No supported autopilots found on the reconnect path and fails test_reconnect_heartbeat_detection_skips_unsupported_components.
  • RESOLVED the download 0600 mode (:961-965 + the os.chmod at :1618 before the os.replace), the staging-file log now naming the destination, the AMC header, the removed FLAKE_CLEAN, and the hardcoded "of 3" in the progress window.

The vendored copy is now within three trivial hunks of upstream. Diffed against a freshly fetched pymavlink#1274 at its current head: 42 diff lines across 3 hunks, down from five — the AMC header line, the removed marker, and the OP_ListDirectoryWithTime shim, which is still justified (the pinned pymavlink==2.4.49 exports 18 OP_* names without it, and upstream defines it as 16, so the fallback value matches). The ftp_param_decode divergence is gone because upstream adopted the offset-based decoder — so the line in my correction addendum saying it "still differs" is now stale, and I am retracting it explicitly since I was the one who raised it.

I am also dropping a finding. I previously flagged the removal of the ImportError fallback in backend_flightcontroller_files.py. You are right to delete it — MAVFTP is now imported only under TYPE_CHECKING, the one runtime import is FtpError, and pymavlink is a hard pinned dependency whose mavftp_op exists in 2.4.49. It was dead defensive code.

What blocks merge is mechanical: the PR breaks its own CI.

Blocking

  • BUG tests/test_frontend_tkinter_parameter_editor.py:1260 and :1334-1336two tests fail at this head. The progress-message change was made in the source but not in these assertions, which still expect 'Reconnect attempt 1 of 3' / '2 of 3' / '3 of 3' while the code now emits 'Reconnecting to flight controller'. Reproduced in a fresh venv by both reviewers: 11 failed / 82–84 passed at this head versus 9 failed at the merge-base e93c7366 with an identical headless setup, the two extra failures being exactly these. (The 9 shared failures are headless-Tk/tooltip and reproduce on the base.) Fix: update the three expected strings.
  • BUG backend_flightcontroller_connection.py:34pylint fails with exit 24 on three diagnostics, all introduced by this head. C0302 Too many lines in module (1003/1000), plus R0904 Too many public methods (21/20) on tests/test_backend_mavftp.py:46 and tests/test_frontend_tkinter_progress_window.py:66. Independently reproduced by both reviewers: that module is 964 lines at the merge-base, exactly 1000 at the previous head, and 1003 now; both test classes have 21 public methods. Three lines over. Extracting a helper, or splitting either test class, clears all three. The last five master pylint runs are green.

Non-blocking

  • ISSUE backend_mavftp.py:961__default_file_mode() is not thread-safe, inherited from upstream. The os.umask(0) round trip is process-global and AMC runs a daemon thread. I originally filed this as theoretical; the cross-check measured it — a three-second concurrent stress run created 115 of 262 files as 0666 instead of 0644, with control creations all 0644. That proves the race, not its frequency in normal AMC use. Worth raising upstream on build(deps): bump astral-sh/setup-uv from 7.2.1 to 7.3.0 #1274 too; reading the destination's own mode avoids the global mutation.
  • NOTE plugins/frontend_tkinter_motor_test.py:586-599 — the restart progress window leaks on the exception path: destroy() sits after the model call rather than in a finally, and set_parameter raises ParameterError on a read-back mismatch before any reset, leaving a stuck "Restarting Flight Controller" window. The cross-check injected the error and observed zero destroy calls. Pre-existing shape, but this PR rewrites these exact lines and auto_close_on_complete=False removes what used to mask it. A try/finally fixes both sites.
  • NOTE (re-raised) the four lint/type/coverage exclusions remain, though the misleading "synchronized verbatim" comment is fixed. Re-measured what they hide at this head: ruff 202, mypy 8 (all unused type: ignore, i.e. delete-the-comment fixes). The mypy override and the pylint ignore buy almost nothing.
  • NOTE (re-raised) the PR still ticks "No breaking changes" while collapsing two callbacks into one progress_callback in six places. All in-repo callers are updated, so this is checklist accuracy only — and in fairness the checklist reads "no breaking changes or properly documented". Also still open and cosmetic: Callable[..., ProgressWindow] weakens argument checking; "Failed to connect after %d attempts" reports 1 on the normal path; and the conditional progress_window_kwargs dict duplicates a direct keyword argument.

Checked and cleared

The real delta is smaller than it looks — the PR was rebased and master absorbed the docs/locale/image churn, so against its own merge-base at each head the change is 143 lines added, 16 removed across 9 files, and every one maps to a previous finding. With exactly pymavlink==2.4.49 installed, all three changed backends import cleanly, the shim resolves to 16, and every mavutil symbol used across the 12 changed source files exists. vermin -t=3.10 over 324 files reports one 3.11 hit that is already guarded and not in this diff. The mavftp and flight-controller suites give 263 passed in a clean venv. The whole mavftp staging lifecycle was traced: a late duplicate open-ack cannot truncate the destination, os.replace stays within the destination directory so there is no EXDEV, and out-of-order chunks land at the correct absolute offsets. The five new translatable strings are not a defect — the .pot is regenerated automatically by ai-translation.yml on every push to master touching Python.

CI is still moving on this head — I counted 15 passing / 7 failing / 1 cancelled / 1 skipped at report time, having been 4 failing (all pylint) when first snapshotted, so treat the exact numbers as a moment in time.

One structural note, unchanged: this file is a copy of an unmerged upstream PR, and pymavlink#1274 moved three times in the last two days (caa17cc9f0514d94b3d4f7f2c121282b401b7621). It needs re-diffing whenever that PR moves.

@AP-Review

Copy link
Copy Markdown

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Re-reviewed at head 7342b85310; my earlier comment above is superseded. Full report: https://uav.tridgell.net/DevCallReviews/2026_09_17_AIReview/devcall_pr_reviews.html#prMethodicConfigurator-2050

Both blockers from the previous round are fixed — but the fix for one of them broke a different linter, which is why CI is red. The independent cold pass also found two defects in the synced FTP code that neither of us had, and I reproduced both before raising them. Verdict: REQUEST CHANGES.

Previous round

  • RESOLVED — the three failing tests at tests/test_frontend_tkinter_parameter_editor.py:1260/:1334-1336. In a clean venv (pymavlink 2.4.49, Python 3.14, Xvfb) the 9 touched suites give 828 passed, 0 failed; CI's macOS and Windows pytest jobs agree.
  • RESOLVED, but it regressed ruff — see the blocking item below.
  • STILL OPENbackend_mavftp.py:961, the __default_file_mode() umask race. The cold pass reproduced the consequence with a controlled thread interleaving: an unrelated file created 0666 instead of 0644. That demonstrates the race, not its frequency.
  • STILL OPENplugins/frontend_tkinter_motor_test.py:596 (same shape at :515), progress window destroyed before the except rather than in a finally. Injecting a ParameterError shows the error displayed but the restart window never destroyed; automatic closure is explicitly disabled.

Blocking

  • The C0302 fix traded a pylint failure for a ruff failure, and the module is now wedged at exactly the line limit so the obvious fix is unavailableardupilot_methodic_configurator/backend_flightcontroller_connection.py:859. pylint now scores 10.00/10, exit 0: C0302 was cleared by deleting three blank lines, leaving the module at exactly 1000 lines, and R0904 by two suppressions. But the blank line deleted between the docstring summary and its description is precisely the one pydocstyle D205 requires. Verified by mutation: re-inserting that one blank line makes ruff check pass and makes pylint report C0302: Too many lines in module (1001/1000). The merge base is ruff-clean at 964 lines. Suggested fix: don't buy the three lines from blank lines — trim the 22-line docstring (the Args: block restates the signature) or extract a helper from create_connection_with_retry, which already carries five too-many-* suppressions. That frees headroom and lets the D205 blank line come back.

  • A buffered-flush failure bypasses cleanup entirely: staging file left behind, FTP session never terminatedardupilot_methodic_configurator/backend_mavftp.py:1573. self.fh.flush() sits outside the protected block (try: 1599 … except (AttributeError, OSError) 1621 … finally: 1628, which calls __terminate_session() at 1631), and nothing in the dispatch chain catches OSError, so it propagates out of process_ftp_reply. Reproduced end-to-end through the real dispatch chain with an injected ENOSPC, and again unmocked with RLIMIT_FSIZE=1024 and a real kernel EFBIG: the exception escapes, the staging file is left behind, the destination is absent, and no termination request is sent — the session stays open on the FC. This also shows the new write-error handler doesn't cover the path: __write_payload returned success for a 4096-byte write because BufferedRandom buffered it, and the kernel error only surfaced at flush(). Reachable in practice — backend_flightcontroller_files.py:386 downloads .BIN logs to a user-chosen path, exactly where ENOSPC is plausible; the caller catches broad Exception so there's no crash, just a hidden .mavftp_* file in the user's destination directory and a leaked session. Note this one is synced in from upstream (pickaxe attributes it to the sync commit, and upstream master has the identical shape at mavftp.py:948), so the same fix belongs in pymavlink#1274 — but the leak consequence is new to this repo, because the merge base had no flush() and no staging file at all. Fix: move flush() (ideally the fh.read()/fstat block at 1574-1598 too) inside the try: at 1599.

Worth fixing

  • Overwriting a deliberately-private file broadens its permissions, 0600 → 0644backend_mavftp.py:1618. os.chmod(self.temp_filename, self.__default_file_mode()) is applied unconditionally before os.replace. Reproduced with real files and real os.replace under umask 0022: destination 0o600 before, 0o644 after. Pre-PR code on the same scenario, run against a worktree of the merge base, gives 0o600 → 0o600. Unlike the previous item this one is introduced here, not syncedgit log -S "os.chmod" over the PR range attributes it solely to 0b53f2cb, and upstream pymavlink master publishes with a plain open(..., "wb"), which preserves an existing mode. It's in your companion pymavlink#1274 too, so that needs the same fix. The comment at 1615-1617 is right for the new-file case (mkstemp gives 0600); the defect is that it applies unconditionally. Suggested: os.stat the destination first and reuse its S_IMODE when it exists, falling back to __default_file_mode() only when it doesn't. tests/test_backend_mavftp.py:131 hard-asserts chmod.assert_called_once_with("staging.bin", 0o644) and will need updating alongside.

Minor

  • backend_flightcontroller_connection.py:921 — reconnect still gives up after one attempt if any non-autopilot component heartbeats during the reboot window. The ordering half is fixed (:466 now only early-returns on MAV_AUTOPILOT_ARDUPILOTMEGA), but :920-923 returns the selection error directly, bypassing the retry loop, so "unsupported" is terminal even though the detected set is time-dependent while the FC boots. Reproduced with the real detection code: attempt 1 sees only a gimbal, attempt 2 would see the autopilot; reset_and_reconnect returns 'No supported autopilots found' after 1 factory call, and changing :921-923 to raise ConnectionError(error) makes the same repro succeed after 2. Reachable on any UDP/TCP link through mavlink-router/MAVProxy or with a companion computer. I see you encoded the current behaviour deliberately in tests/test_backend_flightcontroller_connection.py:2057 — that's right for a genuinely unsupported board, but during a reboot it misclassifies "not up yet" as "unsupported". Suggested scope: retry only when reconnecting is true.
  • backend_flightcontroller.py:430 — the if reconnect_progress_callback is None and not is_reconnect: branch is behaviourally dead; both arms call the same method and the omitted kwargs equal their declared defaults. Verified by mutation: collapsing the two calls leaves 246 tests passing and fails only the over-specified assert_called_with at tests/test_backend_flightcontroller.py:806. Deleting it removes 8 lines from a module you're trying to shrink.
  • pyproject.toml:271 — the ignore_errors = true mypy override for backend_mavftp buys nothing: with it removed, mypy reports exactly 8 errors, every one an unused type: ignore, each fixed by deleting the comment. Dropping the override restores type checking on ~4000 lines for eight deletions. (The ruff exclusion is a separate argument — that one genuinely hides 202 errors.)

One CI failure is not yours

Publish Tests Results fails in its Check coverage step with Coverage failure: total of 88 is less than fail-under=89. Master's own latest Pytest run fails the identical job and step at the identical 88% (run 35150552244, 2026-09-16T21:26Z), so that gate is pre-existing breakage on master, not a regression here. The other two reds (ruff, Docker Build and Test) are both the D205 diagnostic above.

Checked and clear

The pymavlink sync claim holds, re-verified against #1274's current head 2b401b7621: diffing mavftp.py against backend_mavftp.py gives 42 lines in 3 hunks — the AMC header line, a removed # FLAKE_CLEAN marker, and the OP_ListDirectoryWithTime ImportError shim, which is correct (pinned 2.4.49 lacks the name and yields 16 on import). The "CreateFile""put" change is a real fix rather than a rename — operation_name.lower() == "put" gates upload-completion detection at :3111/:3146/:3182, and all 26 process_ftp_reply( call sites were swept.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AIReview Request an automated AI review; picked up by the reviewprs sweep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants