Network Manager
Connectivity polling with offline backoff, ancestry-aware GitHub update checks, and git-based self-update for source checkouts
Overview
The NetworkManager has three jobs: report whether the head unit is online (and on which
network), tell the user when the main branch on GitHub has moved past the running commit, and --
when OCTAVE runs from a git checkout -- pull that update in place, rebuild and restart. It is defined in
src/managers/networkmanager.{h,cpp} and registered as the QML context
property networkManager in src/main.cpp.
QProcess and
QNetworkAccessManager, whose completion slots run on the main thread, so every signal is emitted
from the GUI thread. Some work is still synchronous: the startup git --version probe, the
click-time dirty-tree check, the 5 s waitForStarted() on each process, the short git queries
(runGitSync(): status --porcelain, cat-file, merge-base,
rev-list) and the rollback reset --hard -- the header comment says they run on the
GUI thread "deliberately: they gate a destructive action, so their answer must be current at the moment of the
click".
The manager targets the GitHub repository RobDeGeorge/OCTAVE (GITHUB_REPO)
and compares the local git HEAD against the tip of the remote main branch.
Architecture
Busy Flags
Each activity has its own busy flag (m_networkCheckBusy, m_updateCheckBusy,
m_updating), so a connectivity probe and an update check can be in flight at the same time, while
a second request for an activity that is already running returns immediately.
Connectivity Detection
Network status is polled every 30 seconds while online. After three consecutive failed
probes the interval backs off to 2 minutes and returns to 30 s on the first success
(kPollOnlineMs / kPollOfflineMs / kOfflineStreakForBackoff). The
first probe runs 2 s after startup so the UI can paint first; the git --version probe behind
canSelfUpdate runs at that same moment rather than in the constructor. The name lookup and the
internet probe are started in parallel.
Network Name Detection
startNetworkNameCheck() and
onNetworkNameCheckFinished() shell out to the platform's network tool. The WiFi command runs
first; if it yields no SSID a second command looks for a wired link and reports the literal string
"Ethernet".
| Platform | WiFi SSID | Wired fallback |
|---|---|---|
| Linux | nmcli -t -f ACTIVE,SSID dev wifi list --rescan no -- first line starting yes:. Without
--rescan no nmcli rescans when the last scan is over 30 s old, taking the radio off channel for
~4 s; on the Pi that stalled phone mirroring over the OCTAVE-Jeep hotspot, which shares the radio, once a minute. |
nmcli -t -f TYPE,STATE dev -- a line containing both ethernet and connected |
| macOS | networksetup -getairportnetwork en0 -- text after Current Wi-Fi Network: |
ifconfig en0 -- output contains status: active |
| Windows | netsh wlan show interfaces -- first SSID line that is not BSSID |
netsh interface show interface -- a line containing Connected and Ethernet |
| Android | No command is run; networkName is set to the fixed string "WiFi". |
|
The manager waits up to 5 s for each process to start
(waitForStarted(5000)) and otherwise lets it run to completion asynchronously; a name check that is
still running when the next poll fires is simply skipped. When neither WiFi nor Ethernet is found the name is
empty: the wired-fallback lambda on every desktop platform (and the failed-to-start path)
calls clearNetworkName(), which clears networkName and emits
networkNameChanged("") so a stale SSID is never left on screen.
Internet Connectivity Check
Reachability is a single HTTPS GET of Google's generate_204 endpoint (a captive-portal probe URL
that returns an empty 204) through QNetworkAccessManager with
setTransferTimeout(5000); any QNetworkReply error counts as offline.
void NetworkManager::startConnectivityCheck()
{
QNetworkRequest request{QUrl(QStringLiteral("https://clients3.google.com/generate_204"))};
request.setTransferTimeout(5000);
QNetworkReply *reply = m_nam->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
bool connected = (reply->error() == QNetworkReply::NoError);
// ... update isConnected, offline streak, poll interval, first-connect update check
});
}
Auto Update Check on First Connect
The first time a probe succeeds (m_autoUpdateChecked) the
manager schedules checkForUpdates() 500 ms later. The flag is never reset, so this happens once
per process; later checks are user-initiated from the About page.
Update Checking Mechanism
The update checker is git-based: it compares the local HEAD against the latest commit on the
main branch and asks git how the two are related. There is no release-tag or version-number
comparison, which has a consequence for packaged builds (see the box below).
Step-by-Step Flow
-
Get local commit hash --
git rev-parse --short HEADin the repository root,repoDir()=QCoreApplication::applicationDirPath() + "/..", which assumes the binary lives one level below the checkout (e.g. build/octave). Failure yields"error"/ "Could not determine local version". -
Query GitHub API --
GET https://api.github.com/repos/RobDeGeorge/OCTAVE/commits/mainwith aUser-Agent: OCTAVE-Updaterheader and a 10 s timeout. The response'sshais truncated to 7 characters; the first line ofcommit.messagebecomesremoteCommitMsg. There is no separate connectivity gate: a failed request yields"error"/ "Could not reach GitHub", and an unparseable body "Could not parse GitHub response". -
Classify the relationship --
localVsRemote()returns one ofsame,ahead,diverged,behind(see below), and the working tree is checked for uncommitted changes at the same time. -
Emit result --
updateStatusbecomes"up-to-date","ahead","diverged"or"update-available"with a matchingupdateMessage;remoteCommit,remoteCommitMsgandworkingTreeDirtyare refreshed. When an update is available but the tree is dirty, the message gains " — uncommitted changes block updating". The manager logslocal=… built=… remote=… relation=… dirty=… status=…at info level. -
Stale binary -- the manager also compares
HEADwith the commit baked into the running binary (OCTAVE_GIT_COMMITin the generated buildinfo.h). If the checkout matches origin/main but the binary was built from another commit, the status is"update-available"with "Source is at <head> but this build is <built> — update to rebuild", so the Update button finishes the job by rebuilding.
Ancestry-Aware Classification
A bare remote != local test cannot tell "behind" from "ahead". Before commit 0c29e64 a
source checkout with local commits reported an update to a commit older than HEAD, and the
self-update path would then have hard-reset onto it. The classifier now asks git:
QString NetworkManager::localVsRemote(const QString &remoteShort, const QString &localShort) const
{
if (remoteShort.isEmpty())
return QStringLiteral("behind");
if (remoteShort == localShort)
return QStringLiteral("same");
// Do we even have the remote commit locally? If the object is missing we
// cannot be a descendant of it, so this is a normal "remote moved ahead".
bool haveRemote = false;
runGitSync({"cat-file", "-e", remoteShort + "^{commit}"}, &haveRemote, 5000);
if (haveRemote) {
bool isAncestor = false;
runGitSync({"merge-base", "--is-ancestor", remoteShort, "HEAD"}, &isAncestor, 5000);
if (isAncestor)
return QStringLiteral("ahead"); // remote commit is already in our history
}
// Remote commit is unknown to us. If we also carry commits the last-fetched
// origin/main lacks, both sides moved — that is a divergence, not an update.
bool ok = false;
const QString aheadCount = runGitSync({"rev-list", "--count", "origin/main..HEAD"}, &ok, 5000);
if (ok && aheadCount.toInt() > 0)
return QStringLiteral("diverged");
return QStringLiteral("behind");
}
The three git commands run through the blocking runGitSync() helper. Note
that the diverged test compares against the last-fetched origin/main; the
check itself never fetches.
Update Status Values
// updateStatus can be one of:
"" // No check has run yet (About page hides the status row)
"checking" // Currently checking
"up-to-date" // Local matches remote and the binary was built from HEAD
"update-available" // Behind origin/main, or the binary is stale
"ahead" // Source checkout with local commits (dev build)
"diverged" // Both sides moved -- not a safe update
"error" // Check failed (GitHub unreachable, bad response, not a git checkout)
Only "update-available" raises the badge on the settings button
(frontend/BottomBar.qml, frontend/SettingsMenu.qml) and
offers the self-update button. "ahead" and "diverged" are normal states when running
from the source tree and must never trigger an "update" that would move the checkout backwards.
git rev-parse fails in step 2 and the About page shows "Could not determine local version". The
update check is a source-checkout feature today; there is no fallback that compares the build's version tag
against GitHub releases.
Self-Update System
canSelfUpdate is probed 2 s
after startup and requires a .git path in repoDir(), a
CMakeCache.txt in the binary's directory (the build dir it rebuilds) and a
cmake (the cache's CMAKE_COMMAND, else PATH) and a working
git --version; it returns false unconditionally on Android. Packaged builds therefore report false and
the About page shows its "not available" card instead.
git reset --hard origin/main, which discards every uncommitted change
to tracked files. applySelfUpdate() therefore re-verifies state at the moment it is
called for uncommitted changes, and refuses to run when:
- the working tree is dirty (
git status --porcelainis non-empty) --selfUpdateMessage"Uncommitted changes — commit or stash them first", or updateStatusis"ahead"or"diverged", since resetting would discard local commits -- "Local commits would be lost — refusing to reset".
updateStatus; ancestry is not rechecked after fetch or immediately before reset. These checks therefore
do not guarantee preservation of commits or edits made after the checks. The About page additionally hides the update
and "Retry Update" buttons while workingTreeDirty is true and shows a "commit or stash first" warning instead.
Pre-Conditions
bool NetworkManager::checkCanSelfUpdate()
{
#ifdef Q_OS_ANDROID
return false;
#endif
// Packaged AppImage / installer / DMG builds have no .git above the binary
if (!QFileInfo::exists(QDir(repoDir()).filePath(".git")))
return false;
// The update rebuilds the binary in place: it needs the build dir and a cmake
if (!QFileInfo::exists(QDir(buildDir()).filePath("CMakeCache.txt")))
return false;
if (findCmake().isEmpty())
return false;
QProcess gitCheck;
gitCheck.start("git", {"--version"});
if (!gitCheck.waitForFinished(5000))
return false;
return gitCheck.exitCode() == 0;
}
Update Pipeline
The update pulls the new source and then rebuilds the binary in place. The sequence is a
step machine (runSelfUpdateStep() / onSelfUpdateStepFinished()): each
step starts one QProcess and the finished slot starts the next. A double-tap guard
(m_updating) prevents concurrent update attempts.
-
Save rollback point --
git rev-parse HEADcaptures the full current hash (progress "Saving rollback point..."). A failure here is not fatal; the update proceeds without rollback. -
Fetch latest changes --
git fetch origin main(no timeout). Progress "Fetching latest changes...". Failure ends with "Fetch failed: <stderr>". -
Apply update --
git reset --hard origin/main(no timeout). Progress "Applying update...". Failure ends with "Apply failed: <stderr>". -
Configure --
cmake -S <repo> -B <build dir>against the existing cache, so the baked commit hash matches the newHEAD. Progress "Configuring build...". -
Build --
nice -n 10 cmake --build <build dir> --parallel <cores>(no timeout; an incremental build can take minutes on a Pi). Progress "Building... [45%]" (Makefiles) or "Building... [123/456]" (Ninja), parsed from the build output. The binary is copied to octave.pre-update first. On a failed configure or build the source is rolled back, the copy replaces the binary if the build touched it, and the update ends with "Build failed — update rolled back to <hash>" plus the first error line. The linker writes the new binary as a new inode, so the running process is unaffected; the commit hash lives in a generated header included only bySettingsManagerandNetworkManager, so a new commit does not rebuild every source file. -
Confirm --
git log -1 --format=%h %sproduces the final message "Update applied. Now on: <hash> <subject>" (ifgit logfails to start, "Update applied — restart to run it") andselfUpdateStatusbecomes"restart-required";updateStatusflips to"up-to-date"with "Update applied — restart to complete".
Rollback Mechanism
Rollback is git reset --hard <old_commit> (30 s timeout) and is a no-op when step 1 failed to
record a hash. rollback() runs on fetch, reset, configure and build failures, and when git or
cmake fails to start for steps 1-4; configure and build failures also restore the previous binary
(restoreBinary()).
void NetworkManager::rollback()
{
if (m_oldCommit.isEmpty())
return;
QProcess rollbackProc;
rollbackProc.setWorkingDirectory(repoDir());
rollbackProc.start("git", {"reset", "--hard", m_oldCommit});
if (rollbackProc.waitForFinished(30000)) {
// log "Rolled back to <hash>" or "Rollback failed: <stderr>"
} else {
// log "Rollback timed out"
}
}
Progress Reporting
Step progress is relayed through a mutex-protected message slot. Each step calls
setProgress(msg), and the 250 ms m_resultPollTimer picks the message up and emits
selfUpdateMessageChanged when it differs from the current text. applySelfUpdate()
starts the timer before setting the initial "Fetching latest changes..." and finishSelfUpdate()
stops it, so every step's message ("Applying update...", "Configuring build...", "Building... [n%]") reaches
the About page.
Self-Update Status Values
"idle" // No update in progress (initial value)
"fetching" // Pipeline running (About page shows a spinner + selfUpdateMessage)
"error" // Refused, failed, or rolled back -- message says which
"restart-required" // Update applied, restart needed
Application Restart
After a successful self-update the About page offers restartApp(). It first checks that the
binary still exists and is executable, and otherwise aborts with selfUpdateStatus = "error" /
"Restart aborted — the OCTAVE binary is missing". The restart itself is platform-dependent:
- Windows:
QProcess::startDetached(appPath, args)immediately, thenQCoreApplication::quit() - Linux/macOS: the same
startDetached()deferred toaboutToQuit, thenquit(), so the new process starts once manager cleanup has run.
selfUpdateMessage. The build step already proved the code compiles; the check only guards
against quitting with no binary to restart into.
Device Shutdown
The shutdown_device() slot (the power button on
frontend/settings/DeviceSettingsPage.qml) gives vehicle-mounted installs a clean
power-off request: it launches the OS shutdown command detached and then quits OCTAVE, triggering
aboutToQuit cleanup. The manager does not verify that the OS accepted the request or wait for cleanup
before requesting shutdown; the command also depends on the host's permissions.
// Platform-specific shutdown commands (QProcess::startDetached):
Linux: systemctl --no-block poweroff
macOS: sudo shutdown -h +0
Windows: shutdown /s /t 5
Signals Reference
| Signal | Parameters | Description |
|---|---|---|
networkNameChanged | str | WiFi SSID or "Ethernet" changed |
isConnectedChanged | bool | Internet reachability changed |
updateStatusChanged | str | Update check state changed |
updateMessageChanged | str | Human-readable update status |
remoteCommitChanged | str | Latest remote commit hash |
remoteCommitMsgChanged | str | Latest remote commit message |
workingTreeDirtyChanged | bool | Checkout gained/lost uncommitted changes |
selfUpdateStatusChanged | str | Self-update state machine |
selfUpdateMessageChanged | str | Self-update progress text |
canSelfUpdateChanged | bool | Whether git-based update is possible |
Properties Reference
| Property | Type | Notify Signal | Description |
|---|---|---|---|
networkName | str | networkNameChanged | Current WiFi SSID or "Ethernet" ("WiFi" on Android) |
isConnected | bool | isConnectedChanged | True if internet is reachable |
updateStatus | str | updateStatusChanged | Current update check status |
updateMessage | str | updateMessageChanged | Human-readable update message |
remoteCommit | str | remoteCommitChanged | Latest remote commit short hash |
remoteCommitMsg | str | remoteCommitMsgChanged | Latest remote commit message (first line) |
workingTreeDirty | bool | workingTreeDirtyChanged | Checkout has uncommitted changes; self-update is refused while true |
selfUpdateStatus | str | selfUpdateStatusChanged | Self-update state |
selfUpdateMessage | str | selfUpdateMessageChanged | Self-update progress message |
canSelfUpdate | bool | canSelfUpdateChanged | Git-based self-update available |
Slots Reference
| Slot | Parameters | Description |
|---|---|---|
refreshNetwork() | -- | Manually trigger a network status check (Device settings "refresh") |
checkForUpdates() | -- | Start a GitHub update check (About page button; also auto-run once on first connectivity) |
applySelfUpdate() | -- | Begin the git self-update pipeline after re-checking the preconditions |
restartApp() | -- | Restart the OCTAVE application |
shutdown_device() | -- | Shut down the host OS and quit OCTAVE |
cleanup() | -- | Stop timers and kill any running git / network-name process at exit |
System Integration
Registration:
// src/main.cpp
NetworkManager networkManager;
ctx->setContextProperty("networkManager", &networkManager);
QML Consumers
| QML file | Uses |
|---|---|
| frontend/settings/AboutPage.qml | "Check for Updates" button (disabled while updateStatus === "checking"), colour-coded status row with updateMessage, remote commit hash + message card, the self-update card (hidden while workingTreeDirty, replaced by a "commit or stash" warning), progress / error / restart-required states driven by selfUpdateStatus and selfUpdateMessage, and a "not available" card when !canSelfUpdate |
| frontend/BottomBar.qml, frontend/SettingsMenu.qml | Pulsing badge on the settings button while updateStatus === "update-available" |
| frontend/settings/widgets/AboutWidget.qml, frontend/settings/widgets/DeviceWidget.qml | Compact settings tiles: update status text/colour and a "Check Updates" button; connection dot, networkName and a refresh calling refreshNetwork() |
| frontend/settings/DeviceSettingsPage.qml | Green/red dot from isConnected, networkName ("Not Connected" / "No internet access" texts), refreshNetwork(), and the power-off button calling shutdown_device() |
Many bindings guard a null networkManager. src/main.cpp registers this context property before loading QML.
Cleanup
cleanup() is called from the aboutToQuit handler in src/main.cpp
(and from the destructor). It stops both timers and kills and waits (3 s each) on any still-running
self-update git/cmake process or network-name process.
Timers Summary
| Timer | Interval | Purpose |
|---|---|---|
m_pollTimer | 30,000 ms online; 120,000 ms after three consecutive misses | Periodic network status check |
m_resultPollTimer | 250 ms while a self-update runs | Relay step progress to selfUpdateMessage |
| startup single-shot | 2,000 ms, once | git --version probe for canSelfUpdate + first network check |
| auto-update single-shot | 500 ms, once | checkForUpdates() after the first successful probe |
Design Decisions
No worker thread
Every slow operation -- the connectivity probe, the name lookup, the GitHub request and each self-update
step -- goes through Qt's asynchronous QProcess and QNetworkAccessManager, whose
completion slots run on the GUI thread. Signals are therefore never emitted from another thread and no
locking is needed beyond the small progress slot.
Back off when the car is offline
Commit ee5a23e introduced the 30 s → 2 min backoff after three misses and moved the
git --version probe off the constructor. The source comment states the motivation: "a car with no
internet should not pay for an HTTPS timeout twice a minute forever." Recovery is immediate on the first
successful probe, so a hotspot that comes back is noticed within one cycle.
Ancestry-aware update classification
Commit 0c29e64 replaced the hash-inequality test with the same / ahead / diverged / behind
classifier after a dev checkout with local commits was offered an "update" to an older commit. The
cat-file -e check comes first because an unknown remote object can never be an ancestor of
HEAD, which avoids a misleading merge-base failure on the common "remote moved ahead"
case.
Re-verify at click time; assume the worst when git is silent
Because the apply step is a hard reset, applySelfUpdate() re-runs git status --porcelain at the
moment of the tap instead of trusting a check that may be minutes old ("the tree can be dirtied, or commits
made, long after the last check ran"). If git cannot be queried the tree is treated as dirty -- "the safe default
when we cannot tell is the one that refuses to destroy anything". workingTreeDirty is exposed as a
property purely so the UI can hide the button before it "eats real work" (header comment).
Synchronous checks alongside asynchronous work
The header explains why runGitSync() blocks the GUI thread: the queries are short, and their
answer must be current at the instant of the click, not whatever an async check last cached. The network probes, GitHub request and update steps use asynchronous completion; startup and restart checks and process-start waits can still block the GUI thread.
Build before trusting the new code
Code that fails to compile would leave the head unit running a binary that no longer matches its source, or
with no binary at all after a failed link. The pipeline therefore copies the binary to
octave.pre-update, rebuilds, and on a failed configure or build resets the source
to the saved commit and puts the old binary back. restartApp() then only has to confirm that a
binary exists before quitting.
One automatic check, then user-driven
The update check runs once on first connectivity (500 ms later, so the connectivity state has settled) and afterwards only from the About page. There is no periodic polling of GitHub, which keeps the API call count negligible and avoids surprising the driver with a badge mid-journey.
Git as the source update channel
The self-updater is git fetch + git reset --hard + a CMake reconfigure and
incremental build, which is the source-checkout update sequence implemented here. It intentionally does not try to replace a packaged
binary; canSelfUpdate is false there and the About page says so.