* docs(nodedb): make the native node cap unambiguous
The native node cap was stated in four places that disagreed, and the disagreement
already caused a wrong diagnosis: a saturated 200-node database looked arithmetically
impossible because the cap had been read as 248, computed from a header that does not
apply on this platform. The real value is 198.
On portduino MAX_NUM_NODES is not a compile-time constant at all - the variant defines
it as `portduino_config.MaxNodes`, resolved at runtime, default 200 and settable per
host with `General: MaxNodes`. variant.h is reached before mesh-pb-constants.h, so that
header's ARCH_PORTDUINO branch never fires and its plausible-looking 250 is dead code.
- #error-guard the dead branch rather than leave a wrong number where people grep. The
guard found a real defect: seven translation units reach mesh-pb-constants.h without
configuration.h (SerialConsole.cpp, StreamAPI.cpp, PacketAPI.cpp, ServerAPI.cpp,
PiWebServer.cpp, ServiceEnvelope.cpp, MeshtasticOTA.cpp, and test/TestUtil.cpp), so
each was compiling with a different MAX_NUM_NODES - and therefore a different
PACKETHISTORY_MAX - than the rest of the build. Each now includes configuration.h
first. It cannot be included from mesh-pb-constants.h itself: that reaches
SerialConsole.h through DebugConfiguration.h and closes a cycle.
- Name the bare 250 in getMaxNodesAllocatedSize() NODEDB_MIGRATION_LOAD_CEILING. It is a
decode allowance for files written by larger-cap firmware, not a cap, and it read like
one.
- Fix docs/node_info_stores.md, which named the wrong source and a "10-250" range that
is wrong for native, and the copilot-instructions tunables line that said "portduino
250".
* test(harness): give each suite its own scratch HOME and report leftovers
Native suites shared one directory. Every suite that constructs a NodeDB loads and
saves ~/.portduino/default/prefs/ - nodes.proto, config.proto, channels.proto,
module.proto, device.proto, warm.dat, transmit_history.dat - and nothing cleared it,
so state leaked suite -> suite within a run and run -> every run after it. A test run
could also rewrite a real meshtasticd node database on the same machine.
Per-run isolation does not fix this: the leak is generated inside a single run, so the
boundary has to be per suite.
bin/pio-test-isolate.sh runs each suite in its own scratch $HOME, registered as
test_testing_command for env:native and env:coverage so a bare `pio test` and CI get the
same boundary, not just bin/run-tests.sh. It runs the binary unchanged and exits with its
exit code, so PlatformIO's pass/fail is untouched. Overriding HOME here rather than
around `pio` also sidesteps the blocker that a bare HOME= breaks pio's own
~/.platformio/penv/bin/pio lookup.
Leftovers are reported as a second axis, PASS/FAIL x CLEAN/DIRTY, because an unintended
write has no matching assertion by definition - nobody writes TEST_ASSERT for a save they
do not know is happening. The harness asserts it from outside, so it applies to every
suite without the author opting in.
- Only the *set of changed paths* is asserted, never contents. Hashes answer the boolean
"did this change?" and nothing more; content baselines over protobuf bytes would churn
on every NodeInfoLite field added, which is how snapshot suites become noise.
- Deliberate writes are declared in test/state-manifest.tsv - one central file, suite /
flags / mandatory reason. run-tests.sh prints the opt-out count on every run.
- Granularity follows the state flag, so the two ship together: per-test by default
(TestUtil redefines RUN_TEST to checkpoint after each test, naming the exact test that
dirtied things), suite boundary for state=per-suite, where carrying state across test
cases is the declared behaviour.
- A declared write that does NOT happen is reported as MISSING, not folded into DIRTY. It
catches silently broken persistence; a warning for now, since some are conditional.
- Graded AMBER, not RED. With isolation in place DIRTY means "undeclared", not
"dangerous", and a check that lands red on day one gets switched off.
Guard the guard, both halves: state_assert_empty() refuses to run a suite against a
sandbox that is not empty (otherwise the after-diff measures against the wrong baseline
and reports CLEAN while meaning nothing), and bin/test-state-check.sh drives the real
wrapper with fixtures asserting CLEAN / CLEAN / DIRTY / MISSING plus both directions of
the empty assertion. A checker that silently matches everything would otherwise pass
forever.
--write-manifest proposes entries for a human to paste and justify; it never applies
them, and neither does CI.
* test(harness): stop reporting Unity's exit code as a signal
A native suite ends in exit(UNITY_END()), and UNITY_END() returns the failure count.
PlatformIO's native runner reads that non-zero exit code as a POSIX signal number, so
four failures print "Program received signal SIGILL", five print "SIGTRAP", and the suite
is classified [ERRORED] rather than [FAILED].
There is no crash. The signal name tracks the failure count and nothing else - it moved
SIGILL -> SIGTRAP when a diagnostic probe added a fifth failure - and it cost hours of
hunting a memory bug that did not exist, on an env (native) that carries no sanitizer at
all. It also explains the phantom extra test case in the totals: the runner adds a
synthetic entry for the signal it thinks it saw.
run-tests.sh now says so inline whenever a signal line appears, and the three
agent-facing docs say it too.
* test(admin): isolate NodeDB and globals per test
setUp() did `if (!nodeDB) nodeDB = new NodeDB();` and never deleted it, so 83 of the 85
tests shared one never-reset database and never restored config, owner, devicestate or
channelFile. The fixture that does restore them was opt-in and armed by exactly two
tests. The setUp comment claiming the rest "set their own config/region state and are
unaffected" was not true - the admin handlers under test write all four globals.
Route every test through the fixture instead: setUp saves the globals and installs a
fresh NodeDB, tearDown restores and deletes it. The two tests that armed it themselves no
longer need to.
All 85 pass, so nothing was silently relying on the shared state. It costs about 7% of
the suite's runtime (a NodeDB construction is a loadFromDisk plus, with a region set, key
generation) - worth paying to write the phase 3 tests against a clean fixture rather than
83 tests' residue.
Also cap the per-test attribution in the run summary at five entries; the full list stays
in the suite's sandbox.
* test(fs): cover the bounded file-manifest walk
getFiles() runs on every phone sync via STATE_SEND_FILEMANIFEST, and nothing asserted any
of its bounding behaviour. It does execute unasserted from test_stream_api's handshakes,
but the cap, the depth limit, the wasLimited paths, overlong-path rejection and capacity
release were all unguarded.
Eight tests, all describing what the code does today: today's code is already correct
here, since #10778 landed the by-reference collectFiles(), the 64-entry cap, the strlcpy
bounds and the swap-idiom release. They pass on arrival, which is the point - this is the
baseline a later change has to leave alone.
Two things they do not cover, and cannot:
- Moving reserve() outside the __cpp_exceptions guard. Exceptions are on natively, so the
#else branch is not compiled. The suite's job there is to prove that change alters
nothing observable.
- The file.name() null guard. No in-tree backend returns null; the guard is defensive.
The manifest-release test pins the swap idiom rather than calling
PhoneAPI's releaseFilesManifest(), which is file-local. It asserts capacity() == 0, not
just size() == 0 - a size-only check passes on clear(), which is the bug #7924 shipped.
Suite count 43 -> 44, recounted against the directories rather than copied.
* test(admin): assert node-DB metadata saves skip the radio reload
set_favorite_node, set_ignored_node and toggle_muted_node each persist a NodeInfoLite bit
and nothing else. MeshService::reloadConfig() gates its region re-derivation and
configChanged notification on saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS), so a
SEGMENT_NODEDATABASE-only save already skips the live radio reconfigure.
Pure characterization - all three pass on develop. Worth pinning because that reconfigure
is the path implicated in the WisMesh Tag favourite-node crash, and develop asserts
nothing about it: widening the saveWhat mask or reordering the check would currently go
unnoticed.
Ported from the config-save series along with ConfigChangedCounter (an Observer<void *>
counting configChanged notifications, the only externally visible signal that the reload
branch was taken) and TEST_NODE_NUM. They join the existing suite, so no suite-count
change.
* refactor(menu): extract the mute toggle into a named function
The node menu's mute action was inline in a banner-callback lambda, and that lambda only
ever runs via screen->showOverlayBanner() - which is why nothing in MenuHandler.cpp was
reachable from a test. Lift the `selected == Mute` branch into
menuHandler::toggleNodeMuted(uint32_t) and call it from the lambda.
Behaviour-neutral by construction: same statements, same order, same bare saveToDisk().
The null check moves into the function, so the call site no longer needs its own lookup.
Verified by the native build and suite; the byte-identical-image check on a
headroom-constrained nRF52 board was not run locally - CI's firmware-size comment covers
it.
Three tests come with it, all describing today's behaviour:
- the bit flips both ways and no configChanged fires (develop never calls reloadConfig on
this path);
- an unknown node is a no-op rather than a write;
- and the segment mask. Flipping one NodeInfoLite bit currently rewrites all five
segments via bare saveToDisk(). That is asserted deliberately, with the comment naming
it as characterization of a known defect: a pending fix narrows it to
SEGMENT_NODEDATABASE, and when it lands this assertion is expected to change, which
makes the improvement visible in the diff instead of silent.
saveToDisk() is not virtual, so the mask is observed through its effect - remove the five
prefs files, toggle, and see which reappear.
* docs(test): make every suite count a pointer to the canonical one
test/native-suite-count is the registered total and is machine-checked against test/test_*
on every full run and by the suite-count-check CI job. Every other statement of the count
is a copy that drifts: copilot-instructions said 12, AGENTS.md said 19, and the real
number is 44.
Replace both literals with a pointer to the file, say explicitly that no document should
state the count as a literal, and reframe the two suite listings as descriptions rather
than inventories - they carry per-suite information the count does not, so they stay, but
nothing should infer completeness from their length. Register the new FS suite in both.
* test(harness): randomise suite order, reproducibly
Landed last, deliberately. Randomising an order-dependent suite set does not find bugs so
much as convert a silent pass into intermittent red, and the first instinct is to revert
the randomisation rather than fix the coupling. Phases 1-2 removed the coupling; this
keeps it removed.
Both runners previously hid order dependence behind a fixed order that happened to differ
between them, and neither order was chosen: CI's area rules put admin first, PlatformIO's
local discovery is reverse alphabetical and put it last. CI was green by accident.
- bin/run-tests.sh --shuffle / --seed <n>. The seed defaults to HEAD's short SHA: one
order per commit, so a red is replayable and attributable to the diff instead of flaky,
while the project keeps exploring orders. Printed at the start and carried into the
RESULT line, so a verdict is replayable from that line alone; the full order is printed
on failure, because for an order-dependent failure the order is the diagnostic.
- The shuffle is a Fisher-Yates over a MINSTD generator rather than awk's rand(), whose
sequence differs between gawk and mawk. A seed that does not reproduce the same order on
another machine is not a seed.
- Shuffling needs one `pio test -f <suite>` invocation per suite - PlatformIO orders by
its own os.walk() over test/ and filters only select - which measures at about 4.7s per
suite of extra startup.
- CI shuffles its area order, seeded from GITHUB_SHA and printed with the command to
replay it locally. Intra-area order stays PlatformIO's; controlling it there would mean
per-suite invocations, which is a cost worth deciding separately.
Also records the 16 measured entries in test/state-manifest.tsv, each with its reason,
taken from a full run's --write-manifest output rather than guessed.
* test(default): cover the region-throttle interval overload
getConfiguredOrDefaultMsScaled(configured, default, nodes, TrafficType) is the overload
every telemetry and position module actually calls, and nothing referenced TrafficType
anywhere under test/. All four of its behaviours were unguarded: the no-region guard, the
throttle <= 1 short-circuit, the multiply, and the 64-bit overflow clamp.
The throttles are real, not hypothetical - EU_866 carries PROFILE_LITE, which sets both
positionThrottle and telemetryThrottle to 10, so a change here moves broadcast spacing in
that region by an order of magnitude.
Each test pins numOnlineNodes at the congestion threshold and uses ROUTER, which never
congestion-scales, so the coefficient is 1 and the throttle is the only variable. The
overflow case needs a base above INT32_MAX/10, hence three days rather than one.
* ci(test): keep pull-request suite order fixed, seed the rest
Shuffling the area order on every run - including pull_request - would turn a
contributor's PR red for an ordering they did not choose, which is how a randomisation
gets reverted instead of the coupling being fixed. That is the exact dynamic the ordering
work was sequenced last to avoid, and the previous commit walked straight into it.
- pull_request keeps the fixed declared area order.
- push and schedule shuffle, seeded from the commit SHA: deterministic per commit,
printed, attributable, and never blocking someone else's PR.
- A suite_order_seed input on workflow_call and workflow_dispatch overrides both, so a
specific failing order can be replayed anywhere, including on a PR.
The run log prints which mode it took, the resulting order, and the local command to
replay it.
* ci(test): satisfy CKV_GHA_7 and yamllint on the seed input
The seed is reachable through workflow_call, which callers can pass programmatically. The
workflow_dispatch copy tripped checkov's "workflow_dispatch inputs MUST be empty" rule,
and suppressing it was not worth it: replaying a specific order is a local operation, and
the run log already prints the exact bin/run-tests.sh command to do it.
* style(menu): apply the node-ID format convention
RadioInterface.cpp documents the rule: 0x%08x in logs, !%08x in user-facing
display. MenuHandler held every remaining exception - seven logs printing bare
%08X, and two display labels doing the same.
Repo-wide there are now no bare %08X node IDs left in log calls.
* ci(test): pass workflow inputs through env, not shell interpolation
suite_order_seed and github.event_name were spliced into the run: script as
${{ }} text, so a value carrying shell metacharacters would execute as code on
the runner rather than being read as data. semgrep (run-shell-injection) and
zizmor (template-injection) both flag it.
Both now arrive as environment variables and are read as "$VAR".
* refactor(test): share the seeded shuffle between the harness and CI
bin/run-tests.sh and test_native.yml each carried a byte-identical copy of the
MINSTD Fisher-Yates awk. The workflow prints "replay locally: ./bin/run-tests.sh
--shuffle --seed $seed" after a shuffled CI run, and that instruction is only
true while the two agree - drift would be announced by a replay quietly
reproducing a different order than the one that failed.
Extract shuffle_suites() to bin/lib/shuffle.sh and source it from both.
Permutations verified identical across seeds before and after the move.
* fix(test): correct the shared-state MISSING check and summary join
Three defects in the new harness:
state_classify() matched declarations two different ways - state_path_declared()
for "undeclared", a hand-rolled regex for "missing". Interpolating an entry into
an ERE also let a metacharacter in a manifest name match a file that is not the
declared one. Both directions now go through the one helper.
`paste -sd'; '` does not join with "; ": with -s, paste cycles through a
multi-character delimiter one character per join, so paths rendered as
"a;b c;d e". Replaced with an awk join.
test-state-check.sh ran on after a failed cd instead of stopping (SC2164).
./bin/test-state-check.sh: 6/6 fixtures pass, MISSING included.
* fix(portduino): bound General.MaxNodes
MaxNodes was validated only for <= 0. Any positive value, including a typo'd or
pasted-in one, propagates to MAX_NUM_NODES and scales both the node DB and the
nodes.proto decode ceiling - failing at boot with no obvious cause.
The ceiling is a sanity bound, not a capability limit; raise it if a host
genuinely needs more.
* docs(nodedb): reconcile the capacity tables
The property matrix omitted the ESP32-S3 100-node flash tier that the platform
table above it lists, and neither mentioned that the WASM build overrides
MaxNodes to 80 in wasm_config_apply().
* fix(nodedb): make mesh-pb-constants.h self-sufficient on portduino
The ARCH_PORTDUINO #error assumed it was unreachable in a normal build. It is
not: the vendored device-ui sources include this header without configuration.h,
which broke both native-tft docker builds.
Include configuration.h here instead, ahead of every compile-time default -
variant.h overrides MAX_RX_TOPHONE as well as MAX_NUM_NODES, so placing it lower
in the file just moves the divergence to a redefinition. The #error stays as a
backstop for the case where that include genuinely stops providing the cap.
Verified with the native env's own flags: a TU including only this header now
compiles, normal-order use of both macros compiles, and NodeDB.cpp compiles.
* fix(portduino): raise the MaxNodes ceiling to 16000
Marked artificial: nothing in the node DB fails at 16001. 16000 sits just under
the 16384 (128 x 128) population where HopScalingModule saturates its sampling
denominator and starts dropping nodes, so a host inside the bound still gets
meaningful hop recommendations.
* lint(trunk): advise on node IDs logged as bare %08x
RadioInterface.cpp documents the convention - 0x%08x in logs, !%08x in display -
but nothing enforced it, which is how the MenuHandler cluster drifted. 22 call
sites in PacketHistory, NodeInfoModule and PositionModule are still off it.
A trunk linter rather than a CI grep job, because trunk checks changed files:
new violations get flagged without a 22-site cleanup landing in an unrelated PR.
Modelled on the existing too-many-defined definition.
Scoped to values it can tell are IDs - an ID-shaped argument (->num, .from,
getNodeNum) or message text naming one. A 32-bit hex that is not an ID is out of
scope, so the CRC32 logs in ethOTA.cpp are correctly ignored.
Emits "note", trunk's only non-blocking level: "warning" and "info" both exit
non-zero and would gate CI, which is not what a log-format nit deserves. The
pre-existing sites are line-scoped in the allowlist, so a new bad call in those
same files is still caught.
* lint(trunk): stop exempting the known node-id-format sites
The seeded allowlist made the rule green by declaring the backlog acceptable.
Empty it instead, so the 22 pre-existing sites are reported and get cleaned up
by whoever next edits those files.
Costs nothing to do: the rule emits "note", so these are non-blocking either
way. The allowlist stays for its real purpose - a value the linter misreads as
an ID.
* style: log node and packet IDs as 0x%08x
Clears the 22 sites the node-id-format linter reports, so the rule starts from
zero rather than from a backlog nobody can see - trunk suppresses pre-existing
findings by default, so left alone these would not have surfaced on edit the way
an empty allowlist implies.
Format strings only; no argument or control flow changes. The !%08x
user-facing display forms are deliberately untouched - that is the other half of
the same convention.
* test(harness): build once up front, so suite timings mean something
run-tests.sh fused build and run in a single pio invocation, so whichever suite
PlatformIO's directory walk reached first absorbed the entire src compile and
reported it as its own duration. On a real run that made a 0.03s suite report
13m21s, and hid the build cost from every other number in the summary.
Do what .github/workflows/test_native.yml already does: one --without-testing
build pass, then run with --without-building. Measured on a full 44-suite run -
the build is now a single reported figure and 968 test cases execute in 1.9s,
with no suite above 0.084s.
Build output goes to its own log rather than $LOG: the outcome regexes match
"error:" and "[ERRORED]", so a compiler diagnostic sharing that file would read
as a test failure.
Both red paths now keep the log they quote from. $LOG and the build log are
mktemps the EXIT trap removes, so the three grepped lines were previously all
anyone ever saw - and the cause is usually further up than the first [FAILED].
* test(harness): keep the run log on every red path
bin/pio-test-isolate.sh already keeps a failing or DIRTY suite's sandbox and log
under .pio/test-state/<suite>/. What was missing is the cross-suite view: $LOG is
a mktemp the EXIT trap deletes, so run-tests.sh quoted three grepped lines from a
file that no longer existed by the time anyone looked.
Preserve it as .pio/build/<env>/test-failure.log from both red paths - including
"no success summary found", which said "see log" while preserving nothing, and
which is exactly the case where the build died before any suite ran and so left
no per-suite sandbox either.
Cleared at the start of every run, so a green run cannot leave a red one's log
lying around looking current.
* fix(test): report the real failure count on a shuffled red
A shuffled run is one `pio test` invocation per suite, all appending to the
same log, so the log carries one PlatformIO "N test cases:" summary per suite.
verdict_red() took `tail -1`, which reports whatever the LAST suite did: a
failure in suite 3 printed a "0 failed" summary from suite 44 directly under
"RED - failures detected:".
Sum the summaries instead. A single summary line - every unshuffled run - is
passed through verbatim, so the familiar output is byte-identical.
The patterns are passed to the awk helper as strings rather than /regex/
literals: awk evaluates a regex literal in argument position as `$0 ~ /re/`,
so the callee would receive 0 or 1 and silently sum garbage.
* fix(test): do not emit an empty suite name for an empty shuffle
`printf '%s\n' "$@"` with no arguments still writes one empty line, and both
callers read shuffle_suites through mapfile, so an empty suite list arrived as
a single suite named "". Return before the printf when there is nothing to
shuffle.
* test(harness): state and enforce the Linux host requirement
The native harness is a Linux tool: bash 4+ (mapfile), GNU coreutils and GNU
find (-printf, md5sum, -executable). Most of that predates this branch -
mapfile and both find predicates are already on develop - but none of it was
written down, so the requirement was there to be discovered rather than read.
Refuse to start on a non-Linux uname instead of degrading. On a BSD userland
this would not fail cleanly: it would mis-hash the sandbox and mis-read the
suite list, and still print a verdict. A state check that silently measures
the wrong thing is worse than one that declines to run.
Carrying a per-host fallback was the alternative, and it buys a second code
path that nothing in CI exercises. bin/test-native-docker.sh already exists
for macOS and non-Linux hosts, and the native-macos PlatformIO env is a build
target for meshtasticd, not a test host - the isolation wrapper is registered
for env:native and env:coverage only.
Documented in the script header, test/README.md, and both agent docs.
* fix(test): terminate every suite with exit(UNITY_END())
Two sites across two suites ended on a bare UNITY_END(). That ends the
reporting, not the suite: setup() returns, the runtime goes on calling loop(),
and the process runs forever. PlatformIO does not notice - it reports a suite
from its Unity output, not from process exit - so the suite passes, the run
goes green, and the binary stays resident. Thirteen of them had accumulated on
one dev box, the oldest 19 hours old.
The costs are quiet by construction:
- the per-suite sandbox is deleted underneath a live process, so its
CLEAN/DIRTY verdict describes what the suite had written when the harness
stopped looking, not what it left behind;
- .gcda coverage and LeakSanitizer's report both flush from atexit handlers,
so a suite that never exits contributes no coverage and gets no leak check;
- each survivor pins its own deleted 94 MB binary, which du cannot see.
One of the two is the #else of an architecture guard, which is the easiest one
to get wrong - it looks like there is nothing to clean up. test_mqtt has a
correct exit(UNITY_END()) in its live branch, so a "does this file call exit()
anywhere" check passes the file whole.
test_serial had two more. develop's serial-config validation rework
restructured that suite - the architecture guard is gone and both remaining
branches now exit correctly - so this commit no longer has anything to change
there; bin/lint-unity-exit.sh, added later on this branch, is what keeps it
that way.
test/README.md gets a section on it, since the skeleton showing the right
shape had not stopped this happening.
* test(harness): detect and reap suites that outlive their run
A suite that never exits was invisible: PlatformIO reports a suite from its
Unity output, so the run stayed green while the binary kept running. Two
checks, because they fail differently.
Runtime, in bin/pio-test-isolate.sh: the sandbox $HOME is mktemp-unique per
suite, so any process still holding it is a survivor of that suite. Matching
on the environment rather than a remembered PID identifies one whatever its
parentage - a fork, a grandchild, a process already reparented to init - none
of which a $! comparison catches. Reaped before the after-fingerprint is
taken, so that fingerprint measures a tree nobody is still writing to, and so
a run cannot leave processes accumulating on the host. Recorded as a sixth
summary column and graded AMBER: the tests did pass, but the CLEAN verdict and
the coverage were measured under a false assumption.
Author-time, as bin/lint-unity-exit.sh, wired into trunk at "note" like
node-id-format: every UNITY_END() must be wrapped in exit(). The rule is per
occurrence, and that is the point - a file-level "calls exit() somewhere"
check passes test_serial and test_mqtt, which have a correct one in their live
branch and a bare one in the #else. Running it over the tree turned up
test_mqtt, which the file-level pass had missed.
It allows `int rc = UNITY_END(); ...; exit(rc)`, used by test_packet_signing
to restore globals between the summary and the exit. That is where the rule
gives ground: capturing and never exiting would leak and is not flagged.
Flagging a correct idiom would push someone to "fix" working code.
bin/test-state-check.sh gains a survivor fixture, asserting the wrapper both
reports and reaps - a detector that only reports leaves the host accumulating
processes, which is half the harm. 8/8.
* fix(lint): make the unity-exit scanner statement-aware
The rule judged one physical line at a time, which reports two kinds of correct
code as bare:
/* a comment that happens to
mention UNITY_END() */ <- interior lines were never stripped
exit(
UNITY_END()); <- exit( and the macro never met
On a probe of both, two of three findings were wrong. This is a note-level rule
whose whole job is advice, and bin/lint-node-id-format.sh already says why that
matters: a false positive costs more than a miss. One that cries wolf gets
ignored, and the real finding goes with it.
Carry /* ... */ state across lines and accumulate logical statements before
testing, with a 12-line cap so one unclosed call cannot swallow the rest of the
file - the same structure lint-node-id-format.sh uses, so the two custom linters
in bin/ work alike rather than each having its own idea.
Verified both directions: the develop-era sources still produce the same four
findings, the fixed tree produces none, and a probe covering block-comment
interiors, wrapped exit(), line comments, return UNITY_END() and capture-then-
exit reports only the genuinely bare calls - including a complete block comment
followed by real bare code on the same line, which the state machine has to
keep live.
Reported by CodeRabbit on #11322.
* fix(lint): tokenise instead of pattern-matching, and self-test it
Second round of review findings on the same scanner, all confirmed by direct
test before changing anything. Six defects, one root cause: layered regexes
cannot tokenise C++.
False positives (correct code reported):
- UNITY_END() inside a string literal read as code
False negatives (real leaks missed):
- a string containing "/*" opened comment state and swallowed later lines
- greedy .* removed everything between two block comments on one line,
taking a bare call with it
- myexit(UNITY_END()) matched the exit() exemption as a substring
- x == UNITY_END() and total += UNITY_END() matched the assignment exemption
Replaced with a character-level scan carrying comment state, and token-bounded
exemptions: exit must be a whole identifier, and the capture form must be a
plain `=`. Raw string literals are still not modelled - there are none under
test/, and delimiter tracking for a case that does not occur would be untested
code guarding untested code, so it is documented rather than guessed at.
Also drops the `return UNITY_END()` exemption. It only terminates from main(),
there is no main() under test/, and from a helper it just returns a count.
bin/test-lint-unity-exit.sh pins all fifteen cases, every false positive and
false negative found in review among them. The rule has been wrong twice in a
way that looked fine by inspection; it needed a self-test more than it needed
another careful reading.
Two further findings in the same review:
- bin/run-tests.sh dropped PASSTHRU in shuffled mode, so `--shuffle -vvv`
built verbosely and then ran quietly. The shuffled loop now forwards
EXTRA_ARGS, which is PASSTHRU minus the -f pair it supplies per suite.
- bin/run-tests.sh did not guard `cd "$ROOT_DIR"`.
And one that did not reproduce: the survivor fixture's glob does find the pid
file (verified with the lookup instrumented - the earlier failure was an
artifact of running the script from /tmp, where SCRIPT_DIR cannot resolve).
The assertion was still weak, because an empty pid took the "not running"
branch and passed vacuously. It now fails if the pid was never recorded, and
finds the file by search rather than assuming a directory depth.
Reported by CodeRabbit on #11322.
* fix(lint): report each UNITY_END occurrence at its own location
The self-test only asked "did the linter say anything", so it could not have
caught a wrong line, a wrong column, or a missing second finding. Fixtures now
assert the exact diagnostics as line:col, and the first run of that assertion
found two real problems.
The caret pointed at the wrong occurrence. For `exit(UNITY_END()); UNITY_END();`
the verdict was right but the column was 17 - the wrapped call - because the
scanner stripped terminating forms out of the whole statement and then reported
the first occurrence it had seen. Two bare calls on one line reported once.
Judged per occurrence now, by looking back through whitespace at what wraps it,
so both the count and the caret are right. That also needed a position map from
strip_noncode(): removing a comment or collapsing a literal shifts every later
column, and counting occurrences in the raw line does not recover it either -
TEST_MESSAGE("... UNITY_END() ..."); UNITY_END(); has two occurrences in the raw
text and one in the code.
Four of the expected columns I wrote by hand were also wrong, off by one. The
linter was right in every case; the assertions were not. They are computed from
the fixture text now rather than pasted from output, because a baseline accepted
from the tool it is testing asserts nothing.
17 fixtures, including the two-on-one-line case from review and its mirror.
Reported by CodeRabbit on #11322.
* Package meshtasticd for Windows as an MSI
Adds a --service flag connecting meshtasticd to the Service Control
Manager, a WiX MSI installing it as an auto-start LocalSystem service with
config in %ProgramData%\Meshtastic, and a CI step attaching the MSI to
releases.
* Address review comments
Bind workflow expressions to env vars in run: bodies, and build the
service status per call with an atomic checkpoint.
* Fix service stop state and CI lint
Latch the stop under a mutex so a startup report cannot walk the state
back. Ignore the new workflows in semgrep and checkov, as main_matrix
already is.
* Drop the checkov ignore for the winget workflow
Resolve the newest release inside the job instead of taking
workflow_dispatch inputs, so CKV_GHA_7 no longer fires and checkov stays
active on the file.
* Carry the MSI architecture into the winget manifest
Parse it from the asset name instead of defaulting to x64, and fail on a
multi-arch release rather than validating one at random.
* Restore release/.gitignore
* Leave the main matrix alone
Release attachment moves to the matrix rework in #11151. The MSI is still
built and uploaded as a CI artifact.
---------
Co-authored-by: Austin <vidplace7@gmail.com>
Add explicit ci-gate to the matrix workflow, and cleanup conditionals to make them more readable.
Stop gathering artifacts for PRs/merge-queue, as they are not needed and just take up time/space.
* feat(portduino): add `meshtasticd --check` config validator
Users hand-writing files in /etc/meshtasticd/config.d/ get no feedback when a
key is misplaced, misspelled or duplicated: meshtasticd silently ignores what
it does not read, so a broken config looks identical to a working one.
Add a --check mode that loads the configuration exactly as startup does, then
reports what it found and exits:
- Duplicate keys, via the yaml-cpp Parser/EventHandler stream. The Node API
cannot see them because the map is already collapsed by the time it exists,
and yaml-cpp keeps the FIRST occurrence, so a later override is discarded.
- Unknown or misnested keys, against a schema mirroring what loadConfig()
reads, with a hint naming the section a stray key actually belongs to.
- rfswitch_table validation: unrecognised pins, mode rows whose length does not
match the pin list, values that are not HIGH/LOW, and unknown modes.
- Cross-file overlap: every .yaml in the config directory merges into one
portduino_config, so the file loaded LAST wins, the opposite of the
within-file rule. Those files are read in filesystem order, not alphabetical.
- A warning when more than one file defines a Lora section: spidev, spiSpeed,
gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE and USB_PID/VID/Serialnum are
assigned unconditionally with a default every time one is seen, so any of
them not repeated in the last file loaded is silently reset.
- The resolved gpiochip/line for each pin, since a line that exists on the
wrong chip is claimed successfully and then silently does nothing.
Exits non-zero when errors were found so it can also gate CI over
bin/config.d/**, keeping one implementation rather than a second schema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(portduino): flag pins that resolve to -1 in --check
A pin key whose value will not convert to a number falls back to RADIOLIB_NC
(-1) while still being marked enabled, and initGPIOPin() then trips an
assertion inside LinuxGPIOPin rather than failing cleanly. YAML indentation
makes this easy to hit by accident: a stray line under "CS: 8" folds into the
value as a multi-line scalar, so the file parses, the daemon crashes with a
stack trace from a library file, and --check reported "Configuration looks
good" while printing "pin -1" two lines above.
Report it as an error naming the likely cause instead.
Also correct a comment claiming unparseable config.d files are skipped
silently. They are not: loadConfig() prints "*** Exception ..." with the line
and column. It is the discarded return value, not the diagnostic, that makes
the file's absence from the merged config easy to miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(portduino): cover `meshtasticd --check` with fixtures and a fuzz suite
Adds the tests the config validator was missing, and the checks and fixes that
writing them turned up. The theme throughout is configuration that the YAML
parser accepts but that does not mean what it looks like it means.
Tests
-----
bin/test-config-check.sh - 57 assertions driving a built meshtasticd against
test/fixtures/portduino-config (50 fixtures plus two config.d trees). A shell
test rather than a Unity suite because both behaviours under test are properties
of the process: --check is judged by its exit status and printed report, and the
"a normal run rejects a bad config" path ends in exit() inside portduinoSetup(),
neither of which is reachable from a suite that links one translation unit.
Every fixture carries a comment header naming its planted fault and the expected
finding, so it can be read on its own. Coverage:
* a clean config for each of the ten radio module families (RF95, sx1262,
sx1268, LLCC68, sx1280, lr1110, lr1120, lr1121, sim, auto), asserted both
findings-free and resolving to that module, so a silent fallback to sim
cannot pass
* LR11xx rfswitch tables: unrecognised pins, rows longer and shorter than the
pin count, levels that are not exactly HIGH, a missing pins list, more than
five pins, a scalar table, unknown MODE_ keys, a MODE_ row stranded one
level out, and a legal partial table
* the PA gain table in both accepted shapes, entries outside the uint16 range
it is stored in, and more than the 22 points that are kept
* values of the wrong type, split by consequence: the two settings read with
no fallback stop meshtasticd starting, everything else is silently replaced
by its default
* out-of-range and unit mistakes: TCXO voltage written in millivolts, ports
outside their usable range, an over-long StatusMessage
* MAC sources: both keys set at once, a malformed address, an interface that
does not exist
* structural faults: duplicate keys, non-mapping and unknown sections, a key
left at the top level, a sequence at the document root, an empty file,
unreadable pins, unparseable YAML
* cross-file behaviour over a config.d directory, including the switch tables
that do not override each other
* five configs run WITHOUT --check, each of which must still be refused, so
check mode cannot quietly make the normal path permissive
test/test_fuzz_config - adversarial fuzzing of the checker itself, the "the tool
meant to diagnose your config crashes on it" failure mode. Scope is deliberately
narrow: yaml-cpp does the parsing and is fuzzed upstream, so what is exercised
here is our code above the parse, above all the duplicate-key detector, which is
the one hand-rolled piece and walks the raw parser event stream with its own
stack. Groups: the checked-in fixtures as a seed corpus, 3000 byte mutations of
them (flips, truncation, insertion, splicing, deletion), and structural torture
(nesting to 4096 in flow and block style, duplicate keys at depth, anchors,
aliases and merge keys, 64KB keys, 256KB scalars, multi-document files). A
fourth group of random bytes is present but disabled behind
FUZZ_CONFIG_RANDOM_BYTES: it was half the runtime for the least return, since
uniform noise is rejected on the first token. The contract is crash-freedom and
termination under AddressSanitizer, not any particular finding.
CI runs the shell test in the existing native simulator job; the fuzz suite is
picked up by the existing ^test_fuzz_ area rule. native-suite-count 40 -> 41.
The fixtures are exempt from trunk in .trunk/trunk.yaml, since prettier rejects
the duplicate keys and bad indentation that are the point of them.
Checker fixes found while writing the tests
-------------------------------------------
--check reported a clean exit 0 on configs meshtasticd then refuses to boot, the
worst failure a diagnostic tool can have. Four hard exits inside loadConfig()
killed the report before it printed: an unparseable file, an unknown Lora.Module,
MACAddress and MACAddressSource both set, and HUB75 on a build without it. All
are now reported as findings, and all are still refused on a normal run.
New validation: Lora.Module against the accepted spellings, which are matched
exactly and inconsistently cased, with a suggestion when only case differs; a
per-key value type table covering ~85 keys, tested by asking yaml-cpp to perform
the same conversion loadConfig() will so it cannot drift; the PA gain table;
DIO3_TCXO_VOLTAGE, which is in volts and multiplied by 1000, so the millivolt
value everything else uses silently asks for 1800V; APIPort and Webserver.Port
ranges; MaxNodes; StatusMessage truncation; MAC address and source; and an
unreadable ConfigDirectory.
Also fixes a crash: a ConfigDirectory that cannot be read threw an uncaught
filesystem_error from directory_iterator and aborted meshtasticd with SIGABRT,
taking --check down with it. It now fails cleanly.
Two smaller ones: cppcheck's uselessCallsSubstr on the ancestor walk, which was
failing every check job; and the duplicate-key detector's stack pop, which was
unguarded and relied on yaml-cpp emitting balanced events.
Switch tables are the one place "the file loaded last wins" is false. The loader
only ever writes HIGH and never writes LOW back, so a HIGH from an earlier file
survives a later file that clears it and the radio drives the OR of every table
loaded. Confirmed with --output-yaml. Reported as an error for now; the loader
itself is left alone, as that changes RF behaviour.
* fix(portduino): report CH341 pins as adapter indexes, not gpiochip lines
--check printed "Resolved GPIO lines (what meshtasticd will try to claim)" for
every config, listing a gpiochip and line for each Lora pin and advising they be
confirmed against gpiodetect and gpioinfo. For spidev: ch341 every part of that
is false. portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is
ch341 and hands the raw numbers to Ch341Hal, so nothing is claimed from a
gpiochip -- and on Windows and macOS, where a USB adapter is the only way to
attach a radio, there is no gpiochip, gpiodetect or gpioinfo to check against in
the first place. The checker had no ch341 coverage at all: not one fixture used
it, so the whole USB-SPI path went unexercised.
The summary now splits on the transport. A ch341 device gets its pins listed as
adapter indexes with the gpiod advice dropped, and a gpiochip or line mapping
written alongside it is reported: those are read, stored, and never used.
Also: "RF switch table: not set" read as a gap on an SX126x, where there is
nothing to set. setRfSwitchTable() is only ever called for an LR11xx, so absence
is now "not needed for this module" everywhere else, and "not resolved yet" for
auto, which has no module to judge against.
Fixtures: usb-ch341.yaml (clean, the meshstick shape) and ch341-gpiochip.yaml.
CI fix
------
test-native was RED on "config.d overrides are reported", which wanted 2
warnings and got 1. The fixture's two config.d files name different modules, so
which one wins -- and whether the LR11xx-without-a-switch-table warning fires --
depends on the order the filesystem returns them in. That is the very thing the
fixture exists to demonstrate, so the count is no longer asserted; the report's
own order caveat is asserted instead.
Review fixes
------------
The unreadable-ConfigDirectory diagnostic was the one new print in
PortduinoGlue.cpp not gated behind !configCheck, so it landed ahead of the report
header and broke the clean output the rest of the change is careful to keep.
Docs: rfswitch-valid.yaml carries seven modes, not eight, and empty-file.yaml is
comments-only rather than zero bytes.
* style(portduino): trim --check comment blocks and reconcile suite count
Condense the multi-paragraph comment blocks in the --check validator to the
one-to-two-line convention, and bump test/native-suite-count to 42 for the
test_fuzz_config suite added here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppress checkov/CKV_GHA_7 (the workflow_dispatch reason input is a
free-text run label that never reaches the build) and drop the redundant
quotes yamllint flags on the description/default/name scalars.
The native test job ran every test_* suite in a single platformio invocation,
so a failure in the growing suite set could land past the viewable log limit.
Build the test programs once, then run the suites grouped by area in sequential
invocations, each with its own JUnit report and collapsible log. The runs share
one build dir, so gcov coverage still accumulates and a single capture holds the
union. Areas are ordered regex rules with a catch-all, so a new suite always runs.
platformio test discovers and runs whatever test_* directories exist, so
it never notices when test/native-suite-count drifts from the actual
directory count. That reconciliation lived only in bin/run-tests.sh, which
CI does not invoke - so a stale or unbumped count sailed through PRs
(develop itself shipped 38 dirs against a 37 file).
Add a standalone suite-count-check job to test_native.yml that mirrors
run-tests.sh's exact counting logic and fails on a mismatch. It runs on
every PR via main_matrix's test-native job, with no build step so it fails
fast. Bump native-suite-count to 38 to match the current suites.
clod helped too
Adds a scheduled (09:00 UTC) run of the CI build off develop that does
everything a workflow_dispatch build does except create a GitHub release.
Instead it refreshes a single, stable firmware-nightly/ folder on
meshtastic.github.io with the current develop build, leaving that folder's
hand-maintained release_notes.md untouched so it can be edited manually.
On a nightly run (the schedule, or a manual nightly=true dispatch) only the
firmware build + gather-artifacts + the new publish-nightly job run; the
tests/docker/wasm/macOS/debian-src/size jobs and the three release jobs are
skipped, so no release or tag is created.
publish-nightly downloads the per-board artifacts, generates the
firmware-<version>.json release manifest and an index.json version pointer
(read by the web-flasher), then publishes to firmware-nightly/ via the same
peaceiris/actions-gh-pages action used for releases. keep_files:false is scoped
to destination_dir, so it clears stale nightly binaries while leaving sibling
release folders untouched; the hand-maintained release_notes.md is fetched and
carried forward first (fail-closed) so it is never clobbered.
* CI: track RAM (.data+.bss) in size reports and gate on per-env budgets
The 2.8.0 nRF52840 heap regression (99% heap in field reports) shipped
invisibly because CI only tracked flash. On nRF52840 the heap arena is the
linker gap after .bss, so every byte of static .data+.bss growth shrinks the
usable heap 1:1 - RAM needs the same guardrails flash already has.
What's added:
- bin/platformio-custom.py emits ram_bytes (.data + .bss from the ELF, via
the toolchain size tool) into each .mt.json manifest. Heap/stack
placeholder sections are deliberately excluded.
- bin/collect_sizes.py records {flash_bytes, ram_bytes} per env;
bin/size_report.py grows RAM and RAM-delta columns in the PR size report.
Older artifacts without ram_bytes (and legacy int-schema baselines)
degrade to "n/a" instead of crashing.
- bin/ram_budgets.json: per-env RAM/flash budgets, enforced only for envs
listed there. Seeded with rak4631: ram 113,000 (current 110,948 + ~2 KB
slack), flash 786,000 (current 765,192 + ~20 KB; the app region is
0x27000..0xEA000 = 798,720 and the image must stay clear of the
warm-store ring guard in extra_scripts/nrf52_warm_region.py).
- New size-budget-gate CI job runs size_report.py --enforce-budgets and
fails the build on violation; the informational firmware-size-report job
now also renders budget usage into the PR comment.
- src/main.cpp: opt-in boot heap watermark (-DMESHTASTIC_HEAP_WATERMARK_CHECK)
logs LOG_ERROR when less than 20% of the heap is free at the end of
setup(). Off by default; skipped on platforms without heap accounting.
How budgets are raised: deliberately, never automatically. If a change needs
more headroom, bump the env's limit in bin/ram_budgets.json in the same PR
and justify the increase in the PR description.
Verified: python3 bin/test_size_scripts.py (23/23 pass, including ram_bytes
parsing, n/a fallback, and over/under/missing-env budget-gate cases).
* Address review: fail the budget gate closed, fix RAM section matching
- size-budget-gate workflow: drop continue-on-error on the manifest
download and the empty-dir fallback, so the job fails when the data it
gates on cannot be fetched.
- size_report.py --enforce-budgets now fails closed on every
missing-data path instead of trivially passing: empty collected sizes,
a budgeted env that was not built, or a manifest without the budgeted
metric. Report-only mode keeps rendering those as n/a.
- load_budgets() rejects zero/negative/non-integer budgets with a clear
error (a typo'd budget could previously crash budget_markdown with
ZeroDivisionError or silently skip the check); the percentage render
keeps a defensive guard for direct callers.
- compute_ram_bytes(): count RISC-V small-data sections (.sdata/.sbss)
and exclude ESP-IDF .rtc.* sections, which live outside the
heap-competing SRAM.
- Trim the heap-watermark comment in main.cpp to two lines.
bin/test_size_scripts.py: 27/27 - the two fail-open assertions are
flipped to fail-closed, with new report-only counterparts plus cases for
empty sizes under enforcement and malformed budgets.
Reconciles develop to master's latest renovate values for deps bumped on the
2.7 line but never back-merged, so the develop->master 2.8 promotion (#10777)
doesn't regress them. Done as a value reconcile, not a cherry-pick: several
master commits are superseded (5 device-ui bumps, 2 ststm32 bumps), and
develop's stale archive/refs/tags/ URL form actually blocked renovate from
bumping these (the regex expects archive/<version>.zip).
GitHub Actions:
- actions/checkout v6 -> v7 (35 refs)
- actions/cache v5 -> v6
- actions/github-script v8 -> v9 (3 refs)
- actions/stale v10.2.0 -> v10.3.0
Build / platform:
- alpine 3.23 -> 3.24 (alpine.Dockerfile)
- platformio/ststm32 19.5.0 -> 19.7.0
- platformio/nordicnrf52 10.11.0 -> 10.12.0
Libraries:
- Adafruit SSD1306 2.5.16 -> 2.5.17
- SparkFun MMC5983MA v1.1.4 -> v1.1.5
- Sensirion I2C SCD30 1.0.0 -> 1.1.1
- meshtastic esp8266-oled-ssd1306 6bfd1f1 -> 2e26010
Deliberately excluded:
- meshtastic/device-ui digest: coupled to firmware protobuf/API and develop has
diverged hard (NodeDB v25); left for a separate maintainer bump + visual check.
- libpax: develop uses the mverch67 fork (pinned by Arduino-3.x migration #9122),
master uses dbinfrago -- a fork divergence, not a version bump; not reconciled.
- platform-native digest: develop already at 61067ac (equal to master).
* Add ARCH_PORTDUINO_WASM build: meshtasticd in WebAssembly over WebUSB
Compile the full portduino firmware to WebAssembly (Emscripten) so a real node runs in a browser tab or headless Node, driving a LoRa radio over WebUSB through a CH341 — the desktop Ch341Hal path with its libusb backend swapped for a WebUSB one. The native/desktop portduino build is unchanged.
New build env under src/platform/portduino/wasm/ (excluded from the native build_src_filter): WebUSB libpinedio backend, config/FS/region/MAC/PhoneAPI glue, wasm setup/loop, JS WebUSB runtime, and build stubs. bin/build-portduino-wasm.sh runs a standalone cached emcc build to build/wasm/meshnode.{mjs,wasm}.
Six firmware sources gain #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative emscripten_sleep loop, continuous RX, US region default, std RNG, no-popen exec); none affect non-wasm builds.
* PortDuino WASM: CI build job, cross-platform libdeps, configurable adapter
bin/build-portduino-wasm.sh: auto-detect native-macos (macOS) vs native (Linux/CI) libdeps with a NATIVE_ENV override, and use the SAME env's Crypto with an XEdDSA-present guard. Drops the heltec-v3/Crypto borrow — the meshtastic/Crypto pin already ships XEdDSA; the native libdeps cache was just stale. No env pin change.
Add .github/workflows/build_portduino_wasm.yml: build the ARCH_PORTDUINO_WASM target in CI (ubuntu + emsdk, native libdeps) so it can't silently bit-rot; asserts build/wasm/meshnode.{mjs,wasm}.
src/platform/portduino/wasm: add wasm_set_lora_* setters so the JS host can configure any CH341 LoRa adapter (module, USB ids, DIO/TCXO, SPI speed, pins); wasm_config_apply falls back to the MeshToad defaults when unset.
* WASM: build as a first-class [env:wasm] via platform-wasm (retire emcc script)
Replace the standalone emcc build (bin/build-portduino-wasm.sh) with a normal
PlatformIO env, `pio run -e wasm`, using the new meshtastic/platform-wasm
platform (emcc/em++ + Asyncify, the WASM sibling of platform-native). The
portduino WebAssembly node is now built the same way as every other target.
- variants/native/portduino/platformio.ini: add [env:wasm] (platform pinned to
platform-wasm, board wasm). Translates the script's source set into a curated
build_src_filter, the ~30 EXCLUDE_* defines, and the lib set; defines
ARCH_PORTDUINO_WASM in-repo so correctness doesn't hinge on the platform's
board.json.
- extra_scripts/wasm_link_flags.py: the firmware-specific emcc *link* settings
(EXPORT_NAME, EXPORTED_RUNTIME_METHODS, EXPORTED_FUNCTIONS, ASYNCIFY_IMPORTS).
PlatformIO feeds build_flags to compile only, so these must ride LINKFLAGS;
without it the WebUSB Asyncify seam and the JS host's runtime methods are
dropped (the _wasm_* exports survive only via EMSCRIPTEN_KEEPALIVE).
- PortduinoGlue.{h,cpp}: guard the yaml-cpp dependency out of the WASM build
(#ifndef ARCH_PORTDUINO_WASM around the include, emit_yaml/loadConfig/
readGPIOFromYaml). The browser node configures via the wasm_set_lora_* setters
and dead-strips the YAML path; this drops the host yaml-cpp build dependency
entirely. Native is unchanged (guards are inert there).
- portduino_glue_wasm.cpp / portduino_main_wasm.cpp: repair EM_ASM JS that a
formatter had mangled (!== -> != =, regex split) in the prior landing; the
emcc link succeeds regardless, so CI now runs `node --check meshnode.mjs`.
- .github/workflows/build_portduino_wasm.yml: build via `pio run -e wasm`
(artifacts under .pio/build/wasm/), trigger on the shared inputs the env
inherits (root platformio.ini, bin/platformio-*.py).
- NodeDB.cpp: drop the dead ARCH_PORTDUINO_WASM region-default branch (region
now defaults the same as native).
- Crypto renovate pins: add the missing gitBranch so they track upstream.
Output: .pio/build/wasm/meshnode.{mjs,wasm} (ES module, factory createMeshNode).
Verified: pio run -e wasm (against the published platform archive), node --check,
module instantiates in Node with all exports; native-macos + Docker native unit
tests (450/450) still pass.
* Fix name on the Piggystick
* wasm: pin platform-wasm at the GPL-3.0-relicensed commit
platform-wasm's LICENSE was always GPLv3 (matching this firmware), but its
platform.json/README still declared Apache-2.0 (mis-copied from platform-native).
That's fixed upstream in b83fa5b; bump the [env:wasm] pin to it. Build output is
unchanged (license metadata only). Verified: pio run -e wasm against b83fa5b.
* wasm: make reboot() actually restart the node (was a no-op)
In wasm the reboot path is live (main.cpp -> Power::powerCommandsCheck ->
Power::reboot), but Power::reboot's ARCH_PORTDUINO arm tore down SPI/Wire/Serial
and then called the no-op ::reboot() stub — leaving the node running with a dead
radio until the tab was manually reloaded. Triggers include an admin/phone
reboot, factory reset, the "reconfigure failed" path, and the 60 s stuck-TX
hardware watchdog (RadioLibInterface).
- Power::reboot(): add an ARCH_PORTDUINO_WASM arm (before ARCH_PORTDUINO, since
the wasm build defines both) that skips the host teardown and just calls
::reboot(). notifyReboot already let modules persist.
- ::reboot() (glue): hand off to the JS host — browser reloads the tab (NodeDB
state survives via IDBFS, same identity returns); headless calls Module.onReboot
if provided, else logs. Loose !=/== so clang-format doesn't mangle the EM_ASM JS.
- README: document the reboot handoff + the Module.onReboot hook.
Verified: pio run -e wasm + node --check (EM_ASM intact); native-macos unaffected.
* wasm: rename env to native-wasm and run it in the main CI matrix
Rename [env:wasm] -> [env:native-wasm] for consistency with the portduino
native family (native, native-macos, native-tft). The build dir follows to
.pio/build/native-wasm/ (artifact is still meshnode.{mjs,wasm}); the PIOENV
guard in extra_scripts/wasm_link_flags.py, the README, and the companion wrapper
move with it. The board stays `wasm`.
Also wire the build into normal CI: build_portduino_wasm.yml becomes a reusable
workflow (workflow_call) invoked as the `build-wasm` job of main_matrix.yml, so
the WebAssembly node is built like every other platform instead of on a separate
path trigger.
* native-wasm: auto-locate the Emscripten SDK (pre-build script)
`pio run -e native-wasm` failed with "emcc not found" whenever it was invoked
from a shell that hadn't sourced emsdk_env.sh — a VS Code task, an IDE build
button, a bare terminal. Add a pre: extra script that probes the usual emsdk
locations ($EMSDK_ENV, $EMSDK, ~/emsdk, ./.emsdk, the sibling companion
checkout), sources emsdk_env.sh, and imports the resulting environment so the
platform builder and emcc see PATH/EMSDK/EM_CONFIG. No-op when emcc is already
reachable (CI), silent when no SDK is found (the platform emits its own error).
* wasm: address PR review feedback
- js/bridge.js: import CH341 from "./ch341.js" (sibling in this layout), not
"../src/ch341.js" which doesn't resolve here.
- js/ch341.js: a zero-length transferIn while MISO bytes are still outstanding
now throws instead of breaking out with a partially-filled buffer — silent SPI
corruption becomes a loud error, matching the comment above it.
- libpinedio_webusb.c: webusb_set_auto_cs honors the AUTO_CS option (? 1 : 0)
instead of the always-on ? 1 : 1. Runtime behavior is unchanged — Ch341Hal sets
AUTO_CS=0 right after pinedio_init (RadioLib drives the active-low NSS); the
option just isn't set yet at init, so this now correctly defaults off.
- SX126xInterface.cpp: the RX-start error log now names the method actually
called (startReceive vs startReceiveDutyCycleAuto) instead of hardcoding the
duty-cycle name in the WASM branch.
* native-wasm: drop the emsdk bootstrap shim (now in platform-wasm)
The Emscripten SDK auto-location moved into the platform-wasm builder, so the
firmware no longer needs its own pre: extra script. Remove
extra_scripts/wasm_emsdk_env.py and bump the platform pin to the build that
carries the bootstrap. The wasm_link_flags.py post script stays — those exported
fns / runtime methods / Asyncify import seam are firmware-app-specific.
* wasm: use the canonical companion name (meshtasticd-wasm-node)
The companion repo was renamed meshtastic-web-node -> meshtasticd-wasm-node; fix
the stale name in the wasm README and bump the platform pin to the build that
promotes the canonical name in its emsdk auto-location.
* wasm: re-entrancy guard for the API/region entry points + flaky-open retry
Two robustness fixes for the browser node:
- Re-entrancy guard. The node is single-threaded + Asyncify: while setup()/loop()
is suspended inside a WebUSB transfer, the JS event loop is free, so a stray
DOM/timer callback that re-enters a wasm_* entry point starts a second Asyncify
unwind ("async operation already in flight" abort) or clobbers shared PhoneAPI
state (observed as a "PhoneAPI::available unexpected state" flood). Add a
g_wasm_in_firmware flag set around setup()/loop() (portduino_main_wasm.cpp); the
wasm_set_region / wasm_api_to_radio / wasm_api_from_radio / wasm_api_available
entry points now reject a mid-tick call (return busy) instead of corrupting or
aborting. The host must still call them between ticks — this is the safety net
the design lacked, not a substitute for the JS queue.
- CH341 open retry (js/bridge.js). First-connect WebUSB is flaky — the interface
is briefly unclaimable right after the grant, or held by a prior session,
giving a transient "Could not open SPI: -1". Retry the open with a short
backoff, closing the device between attempts so claimInterface starts clean.
* wasm: exclude emscripten-only sources from cppcheck
The `check` board matrix runs `pio check` (cppcheck) over all of src/,
including src/platform/portduino/wasm/. cppcheck can't parse the EM_ASYNC_JS/
EM_JS macros (Syntax Error: AST broken at libpinedio_webusb.c:39,
internalAstError) and these sources are not part of any checked board build
([env:native-wasm] is board_level=extra, compiled by the build-wasm CI job).
Suppress the wasm dir in suppressions.txt, the same way generated/ and .pio/
are already excluded.
* wasm: coalesce FS.syncfs so two never run at once
IDBFS syncfs is async; the explicit wasm_fs_sync (5s timer + post-save +
beforeunload) could overlap a prior in-flight sync, warning "2 FS.syncfs
operations in flight at once". Serialize: if a sync is running, mark a pending
re-sync and let the in-flight one chain it on completion — at most one in flight,
trailing writes still flushed. (Companion drops IDBFS autoPersist so this is the
single persistence path.)
* wasm: silence false-positive SAST on the emscripten glue
- extra_scripts/wasm_link_flags.py: restore the trunk-ignore-all(ruff/F821,
flake8/F821) header every other SCons extra_script carries; Import/env are
SConscript-injected globals, so ruff/flake8 flag them as undefined.
- .semgrepignore: exclude src/platform/portduino/wasm/js/ (browser WebUSB glue,
not part of the firmware binary). The unsafe-formatstring rule false-positives
on its benign retry/diagnostic console logs.
* Update .github/workflows/build_portduino_wasm.yml
Co-authored-by: Austin <vidplace7@gmail.com>
---------
Co-authored-by: Austin <vidplace7@gmail.com>
* ci(test report): drop no-status testsuites from the PlatformIO report
PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_*
dir x every hardware variant it can't run on native (~4900 rows). They carry
no pass/fail/skip status and bury the suites that actually ran in the dorny
Test Report. Strip them (in generate-reports, before the reporter) so the
report shows only real results. The uploaded artifact keeps the full XML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add bin/run-tests.sh: standardised local test verdict
Self-contained RED/AMBER/GREEN runner: matches all pass/fail spellings and
cross-checks the suite count against test/test_*/ so a missing suite shows AMBER.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* run-tests.sh: detect sanitizer faults + live build/test progress
Two usability improvements to the local verdict script:
1. Sanitizer-fault detection. A sanitizer-instrumented (coverage) build aborts
non-zero at EXIT on an ASan/LSan/UBSan/TSan fault — most often a LeakSanitizer
leak — AFTER every test has printed [PASSED], so pio reports it as
[ERRORED]/SIGHUP with no :FAIL: anywhere and it masquerades as a phantom
failure. verdict_red now recognises the documented fault signatures (ERROR:/
WARNING: <San>:, SUMMARY: <San>:, Direct/Indirect leak of, heap-use-after-free,
runtime error:, etc. — not the benign "failed to intercept" startup noise) and
reports e.g. "RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: ...",
plus the "run the binary bare (gdb hides it via ptrace)" recipe. Also flags the
"all tests passed but aborted at exit" shape when the report was swallowed.
2. Progress trail. Long rebuilds were silent for minutes. A background heartbeat
now appends a status line every few seconds to .pio/build/<env>/.runtests-progress
(always — tail -f it to check a backgrounded/piped run) and live-updates the tty
for interactive --quiet runs: build = objects recompiled / cached total + ETA;
test = suites done / expected. The object-count baseline is cached per env in the
gitignored build dir. Writes never touch the parsed verdict log; tty writes are
guarded so a no-tty run emits no redirect-open error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix the fix
* Address Copilot review: add EXIT trap and clarify PKC comment
Add `trap` to kill meshtasticd on any early exit (python harness
failure, socket timeout) so CI never leaks a background process.
Reword the ARCH_PORTDUINO comment to make explicit that pki_encrypted=true
causes the from==0 plain-admin branch to be skipped, routing into the
PKC key-check — the underlying logic was correct all along.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Update PORTDUINO comment to reflect from==0 auth fix
The from==0 branch no longer requires !pki_encrypted (fixed upstream
in this branch), so update the simulator comment to reflect the actual
remaining reason for the early intercept: is_managed could still block
exit_simulator even for local packets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>