Skip to content

feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API - #32

Open
dakejahl wants to merge 8 commits into
mainfrom
feat/overhaul
Open

feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API#32
dakejahl wants to merge 8 commits into
mainfrom
feat/overhaul

Conversation

@dakejahl

@dakejahl dakejahl commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Supersedes #29, #30 and #31 — this branch contains their commits unchanged, with authorship intact, so merging this merges all three. They can be closed.

Reviewing that stack turned up structural problems in the code underneath it, and fixing those meant rewriting the parts they sit on, so keeping four PRs would have meant reviewing #29's ServerInterface changes and then reviewing their replacement. One branch instead: MAVLink FTP for transport, fetching driven by request rather than by mirroring the SD card, and an HTTP API for the ARK-OS Logs page to drive.

Verified end to end against PX4 SITL and a local flight-review. Adds tests and CI, which the repo had none of.

Problem

Transport. LOG_DATA (msg 120) has no target_system/target_component, so a router between logloader and the autopilot has no addressing to work with and copies every chunk to every endpoint it serves, telemetry radio included. LOG_ENTRY compounds it: it identifies a log by index and timestamp rather than by file, so anything downloading over FTP while listing over the log protocol has to map one onto the other by size, mtime or list ordering — and ArduPilot's numbering, its LOG_MAX_FILES wrap and its .BIN naming all have to be modelled to do it. When that mapping guesses wrong, the wrong file transfers in full and is discarded.

Structure. ServerInterface was 860 lines doing two unrelated jobs: a SQLite repository and a Flight Review HTTP client. About 70% of it was database code and the name described the other 30%. Because the database lived inside the upload target, download state was duplicated across local_server.db and remote_server.db, and LogLoader kept them in step by calling every mutation twice. The download queue was read from whichever happened to be first.

Behaviour. logloader tried to mirror the vehicle's SD card. A companion powered on for the first time, or one whose database had been reset, would pull and upload the entire history over a link with better things to do — and there was no way to see what it had or to ask for a particular log.

Defects. A pending log whose local file was missing or empty was retried forever and blocked every older log behind it; only HTTP 400 escaped the queue. ServerInterface::_should_exit and LogLoader::_loop_disabled were plain bools written by one thread and read by another. The signal handler took a mutex and signalled a condition variable, neither of which is async-signal-safe. CMAKE_BUILD_TYPE was never set, so the shipped binary was built at -O0. generate_uuid hashed with std::hash — implementation-defined — into state persisted on disk.

Solution

Everything over MAVLink FTP (FILE_TRANSFER_PROTOCOL, msg 110), for the listing and the transfer. It is addressed, and both stacks reply to the requesting sysid/compid, so transfers are unicast. Identity becomes path-below-the-log-root plus size, so there is no cross-protocol mapping left to get wrong and ArduPilot needs no special handling — a directory listing just reports files. The root is probed at @MAV_LOG, then /fs/microsd/log, /APM/LOGS and /log. MAVSDK's Ftp plugin discards size and mtime from list replies, so FtpListClient runs the listing over MavlinkPassthrough; worth upstreaming so it can be deleted.

Storage split from transport. LogDatabase owns the inventory and the intent, UploadTarget is one Flight Review endpoint with no state of its own, and LegacyImport quarantines the pre-FTP migration behind a header saying it is deletable once the fleet has upgraded. One database, one place to look.

Fetching is request-driven. A log is fetched because it appeared while logloader was watching — a flight just ended — or because something asked. On a database that has never seen the vehicle, only the newest is taken. A listing with more than download.max_auto_queue new logs is an unfamiliar card rather than a burst of flights, so it queues one and says so. Intent lives in the database, so automatic and requested work share one path.

An HTTP API on 127.0.0.1:3005: GET /logs, /status, /events (SSE), POST /logs/{download,upload,cancel}, DELETE /logs/{id}/file. ARK-Electronics/ARK-OS#109 is the page that drives it.

Uploads stream off disk with a computed Content-Length. httplib's MultipartFormDataItems overload concatenates the whole body into a second string; a 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM kill retried the same log on restart. Measured: 16 MB RSS while uploading a 76 MB log.

Also fixed, most of it found by review or by the SITL run: a use-after-free where FtpListClient handed MAVSDK a this-capturing callback (MAVSDK unsubscribes asynchronously, so a reply arriving after shutdown wrote into freed heap); an ODR violation from defining CPPHTTPLIB_OPENSSL_SUPPORT per-file, which gave two translation units different socket layouts and segfaulted on the first real run; Transaction::commit clearing its flag before COMMIT, so a failed commit left the transaction open and every later write was silently discarded while the daemon looked healthy; a missing ALTER TABLE for a column added to an existing schema; and a log still being written being treated as absent, which marked every row "not on vehicle" for one interval after each restart.

Rows are marked absent rather than deleted when a log leaves the vehicle, so a downloaded log keeps its file and its upload history. Config tables are one level deep on purpose — ARK-OS's shared config editor renders exactly one level, and a setting the operator cannot reach from the web UI may as well not exist. The old flat keys are still read, with a deprecation warning.

Upgrades. Pre-FTP databases keyed logs on the LOG_ENTRY timestamp, which FTP cannot reproduce. local_server.db and remote_server.db are folded into one logloader.db and matched to the listing by size, so a fleet upgrading does not re-download and re-upload its history. A log the old version had downloaded but not yet uploaded keeps its file and its state but is not queued — carrying that intent over would produce exactly the flood this version exists to avoid.

tests/test_log_database.cpp covers 16 cases including the legacy import, which runs once against real user data and cannot be un-run. Writing them found two defects on its own.

Carried over from #30 and #31

The reachability cooldown and the API-key headers are preserved in UploadTarget, with one change: 401/403 no longer blacklist a log. Those statuses mean this account is not authorized yet, not this log is bad, and the expected first-run state returns 403 — blacklisting would silently discard one pending log per cycle. The batch still stops on them, which resumes on its own once the key is valid.

Worth flagging: the API-key client has no server counterpart. The ARK flight_review fork has no api_key.py on any branch, and UploadHandler.prepare() authenticates with a session cookie plus _is_uploader_approved, 403ing otherwise. The plumbing is harmless and forward-compatible so it is kept, but setting remote_api_key currently changes nothing.

Follow-ups

  • Bump the ARK-OS submodule pointer — feat(logs-page): add a Logs page for browsing and fetching vehicle logs ARK-OS#109 does this and depends on it.
  • Confirm ARK's shipped PX4 configs start the companion MAVLink instance with -x; FTP is now load-bearing.
  • Resume interrupted transfers rather than restarting them. A 76 MB log timed out once mid-test and was refetched from scratch; the failure-count ordering keeps it from blocking the queue, but resume is still worth doing.

dakejahl and others added 8 commits July 27, 2026 21:19
LOG_DATA (msg 120) carries no target_system/target_component, so a router
between logloader and the autopilot has no addressing information and copies
every chunk to every endpoint it serves -- the telemetry radio included. On a
node running mavlink-router a log download saturates links that have no
interest in it.

Move both halves of the job to MAVLink FTP: the listing that finds the logs and
the transfer that downloads them. FILE_TRANSFER_PROTOCOL (msg 110) is addressed
and both PX4 and ArduPilot reply to the requesting sysid/compid, so everything
is unicast to logloader. The classic log protocol is gone entirely -- no
LOG_REQUEST_LIST, no LOG_ENTRY, no LOG_DATA, and no MAVSDK LogFiles plugin.

Dropping LOG_ENTRY means dropping the only thing that identified a log, so
identity moves to the listing: the path below the log root plus the size.
That is also what makes ArduPilot work without special cases. Its list entry
numbering, its LOG_MAX_FILES wrap and its .BIN extension were all consequences
of the old protocol; a directory listing just reports files.

The log root is probed at "@MAV_LOG" -- the virtual directory the MAVLink FTP
specification defines for this -- then at /fs/microsd/log and /APM/LOGS for
firmware that predates it. Timestamps come from the listing when the vehicle
implements ListDirectoryWithTime, otherwise from the start time PX4 encodes in
the path; ArduPilot logs simply have no timestamp and nothing needs one.

MAVSDK's Ftp plugin drops the size and modification time from list replies, so
FtpListClient runs ListDirectory / ListDirectoryWithTime over MavlinkPassthrough
and keeps the whole entry. Bulk transfers still go through the plugin.

Databases written by earlier versions keyed logs on the LOG_ENTRY timestamp,
which FTP cannot reproduce. Their rows are set aside as logs_legacy on first
start and matched to the listing by size, so a fleet upgrading to this does not
re-download and re-upload its history.

Also:
- A log is only queued once two consecutive listings agree on its size, so the
  log being written right now is not downloaded at a size it will not keep.
- Downloads are staged and size-checked before being moved next to the finished
  logs, and a log that cannot be fetched sorts to the back of the queue instead
  of blocking the ones behind it.
- Open FTP sessions are reset at connect, which is what left PX4 refusing
  transfers until reboot after logloader was killed mid-download.
- The databases are created before they are opened, which failed when
  application_directory did not exist yet.
When the local flight-review server is down, the upload loop previously
probed and logged a failure for every pending log. Probe once, log a
single unreachable message with a 60s cooldown, and bail the current
upload batch (HTTP 503) instead of walking the queue.

(cherry picked from commit 7a23c8e)
Add remote_api_key config for ARK Flight Review (Authorization: Bearer and
X-API-Key per flight_review api_key.py). Empty key still attempts remote
upload without auth headers for open servers.

Treat any HTTP response as reachable so 302 home pages (review.arkelectron.com)
are not marked dead. Improve 401/403 handling with response body and stop
retry spam. Slightly longer connection timeouts for probe and upload.

(cherry picked from commit adc72da)
…equest-driven

Replaces ServerInterface, which was a SQLite repository and an HTTP client
sharing a name, with LogDatabase (one database, the single record of what the
vehicle has and what we mean to do about it) and UploadTarget (one Flight
Review endpoint, no state of its own). LogLoader no longer keeps two databases
in step by calling every mutation twice.

Behaviour change: logloader no longer mirrors the SD card. A log is fetched
because it appeared while logloader was watching -- a flight just happened --
or because something asked for it. On a database that has never seen the
vehicle only the newest log is taken, so a companion powered on for the first
time does not pull and upload an entire card. A listing that turns up more than
max_auto_queue new logs is treated as an unfamiliar card, not a burst of
flights. Intent lives in the database, so automatic and requested work share
one path.

Adds an HTTP API on 127.0.0.1:3005 (list, request, cancel, delete, SSE) for the
ARK-OS Logs page to drive.

Also fixed along the way:
- A pending log whose file was missing or empty was retried forever and blocked
  every older log behind it; only HTTP 400 escaped. Upload outcomes are now
  classified (Success/Rejected/Unauthorized/Unreachable/Retry) and the loop
  iterates a fetched batch instead of re-counting a query that only shrinks on
  success.
- ServerInterface::_should_exit and LogLoader::_loop_disabled were plain bools
  written by one thread and read by another. Both are gone; pausing is derived
  from arm state and shutdown goes through a Waiter.
- SIGINT/SIGTERM are blocked and consumed by a sigtimedwait thread. The handler
  used to take a mutex and signal a condition variable, neither of which is
  async-signal-safe.
- CMAKE_BUILD_TYPE was never set, so the shipped binary was built at -O0.
- Rows are marked absent rather than deleted when a log leaves the vehicle, so
  a downloaded log keeps its file and its upload history.
- generate_uuid hashed with std::hash, which is implementation-defined, into
  persistent state; identity is now (path, size) with an integer primary key.
- Dead code: num_logs_to_download, the .lock file check, the logger_running
  branch that was hardcoded false.
- The staging directory is swept at startup so a run killed mid-transfer does
  not leak partial files.

Verified end to end against PX4 SITL and a local flight-review: probe, index,
first-start policy, download, upload, and manual selection through the API.
… an OOM upload

Findings from two review passes over the rewrite, verified against PX4 SITL and
a local flight-review.

FtpListClient handed MAVSDK a callback capturing `this`. MAVSDK removes a
message handler asynchronously and does not drain callbacks it has already
queued, so a reply arriving after the client was destroyed -- which is what
happens on SIGTERM during a transfer, since the vehicle keeps sending -- locked
a destroyed mutex and wrote 251 bytes into freed heap. The callback now holds a
weak_ptr to the state it touches. This is the same shape as the progress
callback fixed earlier; it was the second instance.

Transaction::commit cleared _open before running COMMIT, so a COMMIT that
failed (a full data partition) left the transaction open with no rollback.
Every later BEGIN would then fail, every write would be silently discarded, and
the daemon would re-download the same logs forever while appearing healthy.

Uploads serialised the whole log into memory twice: httplib's
MultipartFormDataItems overload concatenates the entire body into a second
string. A 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM
kill retried the same log on restart. The body is now streamed off disk with a
computed Content-Length. Verified byte-identical on arrival.

Also:
- LogDatabase::ok() reported success when only sqlite3_open had succeeded, so a
  read-only data directory produced a daemon that ran and stored nothing.
- A row claiming downloaded=1 with no local file was reachable from neither
  queue. The queues now treat it as not downloaded, and a missing local file at
  upload time is Outcome::Missing (re-fetch) rather than Rejected (give up).
- The SSE keepalive wrote 14 bytes of a 13-byte string, putting a NUL on the
  wire; the frontend then dropped the next real event. Transfer progress also
  re-serialised the whole log table twice a second per client -- status and
  logs are now separate events.
- Each SSE stream holds an httplib worker, and the default pool is 8, so a few
  stale tabs starved the whole API. Pool raised, concurrent streams capped.
- newest_log_id ordered ArduPilot logs by name, which stops meaning age once
  the counter wraps at LOG_MAX_FILES; discovered_at now takes precedence.
- iso8601_utc ignored strftime's return value, so a vehicle reporting an
  absurd mtime caused a stack over-read into the JSON response.
- FtpListClient::stop() mutated its flag outside the lock (lost wakeup), and
  list_directory had no bound on a remote-driven loop.
- download.auto = false no longer leaves latest_on_first_start active; a free
  space check runs before a transfer; `make debug` builds Debug again; astyle
  moved out of the default target.
…se with tests

The pre-FTP migration was 28% of LogDatabase and the one thing in it that was
not a repository: an extra table, a fuzzy timestamp heuristic, a reconstruction
of the old file-naming scheme, a member, a constructor parameter and a branch
inside sync_index. It moves to LegacyImport, whose header says plainly that the
file is deletable once the fleet has upgraded. LogDatabase drops from 796 lines
to 560 and is now only a repository.

sync_index returns a SyncResult (what it inserted, how many are present, and
whether this was the first listing with anything in it) instead of writing two
out-parameters and leaving the caller to own the "first index" invariant. The
subtlety -- that the first listing after startup legitimately reconciles
nothing, because stability takes two listings to establish -- now lives in the
one function that can see it. apply_auto_policy is two obvious halves.

Adds tests/test_log_database.cpp: 14 cases over the first-index rule, present/
absent transitions, a growing log, request and cancel, the download-with-no-file
recovery, rejected uploads not blocking the queue, failure ordering, ArduPilot
name reuse, and four covering the legacy import -- which runs once against real
user data and cannot be un-run, so it is the thing least testable any other way.
Plus a GitHub Actions workflow that builds, tests and checks formatting.

Writing the tests turned up a real defect: ordering fell back to discovered_at,
a one-second wall clock, to decide which ArduPilot log is newest once the
counter wraps at LOG_MAX_FILES. Two listings in the same second were
indistinguishable, and on a companion with no RTC the clock can run backwards.
Replaced with a monotonic discovery sequence.

Also: ATTACH is now RAII, so a failed import cannot leave the old database
attached and wedge the next one; the legacy adopt path no longer produces a row
claiming to be downloaded with no file behind it; and equidistant timestamp
candidates leave the claim with the first rather than the last.
…w findings

A second review pass over the rewrite, verified against PX4 SITL.

The last commit added a discovered_seq column but no migration. CREATE TABLE IF
NOT EXISTS does nothing to a table that already exists, so on any database from
an earlier build every query naming the column failed to prepare and returned
nothing -- while commit succeeded and the daemon reported itself healthy. The
log list would simply be empty forever. There is now an ALTER TABLE path,
backfilled from discovered_at, and a test that opens an older schema; the
existing tests only ever created fresh databases, which is why they missed it.

A log still being written is on the vehicle. sync_index was only told about
stable ones, so the first listing after any restart -- which has nothing stable
yet, stability taking two listings -- marked every row absent, showed "not on
vehicle" across the whole UI, and emptied the download queue for an interval.
The whole listing now goes over with a stable flag; only stable logs are
recorded, but everything listed counts as present.

Outcome::Missing on a zero-byte log was an infinite loop: clear, re-request,
download nothing successfully, clear again. An empty log is a property of the
log, not of the local copy, so that case is Rejected again. Missing now means
only that the file is gone.

clear_local_file is conditional on the path the caller saw. The upload thread
works from a snapshot that can be minutes old, and clearing unconditionally
would wipe the bookkeeping of a download the index thread had just finished and
re-fetch the whole log.

Also: the SSE stream slot is released by httplib's resource releaser, which runs
on every path -- the manual decrement only covered shutdown, so a browser
refresh leaked a slot and eight of them wedged /events permanently; a database
write now bumps the status board, which streams block on, instead of waiting out
a keepalive; upload targets ship their base url, since Flight Review returns a
relative redirect and a remote plot link was resolving against the ARK-OS host;
a failed BEGIN no longer leaves a durable UPDATE behind; an unchanged listing no
longer counts as a change; downloaded is never reported true for a row with no
file; auto_upload keeps its old key and stops hardcoding its default; connect()
cannot leave a fetcher running past stop(); and free space is printed as
uintmax_t in the message that exists to diagnose a full disk.

Config tables are one level deep now ([upload_local] rather than
[upload.local]): ARK-OS's shared config editor renders exactly one level, and a
setting the operator cannot reach from the web UI may as well not exist.
@dakejahl dakejahl changed the title refactor(logloader): make fetching request-driven and split storage from transport feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API Jul 28, 2026
@dakejahl
dakejahl changed the base branch from feat/flight-review-api-key to main July 28, 2026 21:19
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.

1 participant