stop()'s join had a fixed deadline (CONNECT_TIMEOUT_MS + 2s); a slow DNS could
keep the task inside connect() past it, so stop() would free the object while the
task still referenced `this`. Extend the deadline well beyond any connect()+DNS,
and if it still expires, vTaskDelete(_task_handle) the task so it can't touch
`this` after return. (The task's own self-delete path sets _task_done first, so
this branch only runs when it has not self-deleted — no double delete.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Storing _reconnected before _conn_state=CONNECTED left a seq-cst window where the
main loop could observe _reconnected==true while still CONNECTING. check_reconnected()
would then clear the flag and announce on an offline interface (loop() returns
early), so no announce fired once actually connected. Store CONNECTED first; seq-cst
then guarantees _reconnected is only ever observed true on an online interface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
task_loop() set `_online = true` during the CONNECTING window, which races with
loop()'s `_online = false` on the main loop (plain bool, no synchronizes-with).
It's redundant: the main loop sets `_online = true` when it observes CONNECTED.
Removing it eliminates the race with no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
With _last_connect_attempt == 0, task_loop()'s first
`now - _last_connect_attempt >= RECONNECT_WAIT_MS` check only passes once
millis() >= RECONNECT_WAIT_MS, delaying the very first connect up to 15s after
boot. Seed it to millis() - RECONNECT_WAIT_MS in start() so the first attempt
fires immediately (unsigned wraparound keeps it correct when millis() < the wait).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- stop() now waits on a _task_done flag the task sets right before exiting,
instead of a fixed sleep. Closes a use-after-free window where an in-flight
connect() overrunning CONNECT_TIMEOUT_MS (slow DNS) could touch `this` after
~TCPClientInterface() freed it.
- _last_connect_attempt is now std::atomic<uint32_t> — it's read/written by
task_loop() (core 0) and handle_disconnect() (core 1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
The blocking WiFiClient.connect() ran on the main loop and stalled it for the
lwIP default (~18.5s) when the host was unreachable -- including the DNS lookup --
freezing the UI, so the screen took ~10s to wake. The 2-arg connect() also
ignored CONNECT_TIMEOUT_MS (that only bounds reads).
Move only the blocking connect() to a dedicated FreeRTOS task. read/write/frame
stay on the main loop exactly as before (unchanged low-latency data path -- the
link/Resource timing is untouched). An atomic _conn_state hands _client ownership
between the task (while CONNECTING) and the main loop (while CONNECTED) so they
never touch the socket concurrently. Bound the connect via the 3-arg connect()
and back off retries to 15s.
tests/hardware: wait_for_tcp_link() matched "started", keying on interface
startup rather than the actual connect. With the async connect that let the
harness drive the announce before the link was up, so the device's announce was
lost and the first direct message (bz2-probe) failed. Match "connected to".
Verified on a T-Deck: screen wake instant (connect off the main loop); e2e smoke
5/5 including bz2-on-receive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Repins microReticulum + microLXMF onto the upstream-0.4.1 graft and adapts
pyxis to the new src/microReticulum/ layout and 0.4.x APIs. The far-diverged
0.3.0 fork's Resource/Transport/Identity work is subsumed by upstream's
reimplementation; only the still-needed fixes ride on the pinned branches
(PKCS7/HMAC/X25519 crypto -- proven byte-identical to python RNS 1.3.1 --
Packet link-proof callback, Identity short-sig guard, and the bz2 layer +
decompress-on-receive in Resource::assemble()).
Consumer-side changes:
- platformio.ini: pin microReticulum @2f21fee (pyxis-fixes-on-0.4.1) and
microLXMF @33760d0 (chore/microreticulum-0.4.1-layout); bump microStore
ceea8f5 -> c5fb69d (0.4.x requires the new BasicFileStore::init API);
-std=gnu++11 -> gnu++17 (upstream requires C++17).
- Namespace all microReticulum includes (angle + quote) to <microReticulum/...>
for the relocated layout; shim-local Utilities/Stream.h|Print.h preserved.
- Interface::send_outgoing now returns bool: update TCP/BLE/SX1262/Auto
overrides with correct success/failure returns.
- SDArchiveFileSystem::init(bool reformatOnFail=true) to match new microStore.
- Static Transport::get_path_table() -> path_table(); instance getter unchanged.
- Remove duplicate shim Cryptography/BZ2 (microReticulum provides it now; keep
lib/libbz2 as the ESP32 bzlib provider).
- patch_littlefs_paths.py: normalize microStore's LittleFS adapter paths to a
leading "/" -- ESP32 Arduino LittleFS rejects "./"-prefixed paths, which
silently broke the path store (no peer paths learned, all messaging blocked).
Validated on T-Deck Plus: builds (RAM 27.5% / Flash 77.7%), boots stable
(no WDT/panic), and a full on-device LXMF e2e (DIRECT + OPPORTUNISTIC +
bz2-compressed-Resource receive) passes 5/5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Real-LXST 14s call at ULBW (Codec2-700C) was timing out T:CALL_QOS /
T:CALL_STATS responses ~10-15s in. Pyxis itself was still processing
audio fine; the host's serial reader was just overrun by debug-level
prints from three sources, all firing per-packet during voice traffic:
1. TCPClientInterface: per-frame "[TCP] Reading X bytes" / "[TCP]
First bytes: ..." / "[HDLC] Frame #N: ..." / "[TCP] Processing
frame" / 5s "[TCP] connected= ..." were unconditional Serial.printf.
Now gated behind `RNS::loglevel() >= LOG_DEBUG` and the snprintf
work skipped when it'd be discarded.
2. i2s_capture.cpp: "[CAP] rate=" fired every 2s regardless of
activity. Now only emits when ringDrops > 0 OR runningPeak > 1000
(something happened worth noting). Counters still update — only
the print is gated.
3. microStore upstream: "[ustore] get: key not found in index" fires
on every path-store miss, which RNS hits constantly during a call.
patch_filestore.py was already a registered pre-build script for
diagnostic patches; reactivate it (was commented out in
platformio.ini) and add a silence patch as the always-on default.
Diagnostic exists()/put() patches gated behind PYXIS_FILESTORE_DIAG=1
so they're easy to bring back when investigating path-store drift
without touching the script each time.
After this, the ULBW real-LXST call validator returns PASS with full
final stats (pyxis_tx=34 rx=119 decode_ok=151 decode_fail=0
pyxis_rms=4410). 1600bps/3200bps profiles still hit serial-timeout
patterns under sustained TX — likely CPU saturation in the main
loop, separate from this fix; tracked in #75 followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WIRE TX raw + WIRE TX framed were INFO-level, ~150-180 bytes each, and
fired per outgoing packet. During an LXST voice call (~5 batches/sec
plus retries) that's ~2KB/s of pure debug log on USB CDC, on top of
existing call/heap/disp prints. The serial buffer saturated ~15s in
and T:CALL_QOS / T:CALL_STATS commands timed out at the 5s threshold,
even though the device itself was healthy.
Demote both to DEBUG and gate the hex-encoding work behind a runtime
loglevel check so the per-packet snprintf loop doesn't run when the
output would be discarded anyway. Re-enable by raising RNS log level
to DEBUG when actually debugging the wire format.
After this, a 14s real-LXST E2E call returns full stats every poll
with no timeouts and the harness validator runs to PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split T-Deck firmware from microReticulum examples/lxmf_tdeck/ into its
own repo. microReticulum is consumed as a git submodule dependency pinned
to feat/t-deck. All include paths updated from relative symlinks to bare
includes resolved via library build flags.
Both tdeck (NimBLE) and tdeck-bluedroid environments compile successfully.
Licensed under AGPLv3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>